oyo 0.1.69

CLI TUI for oyo - Step-through diff viewer
use super::{App, FileDiskStamp};
use crate::remote::stream::{self, RemoteEvent};
use crate::toasts::ToastEvent;

pub(super) type ConfigureRemoteReview = Box<dyn Fn(&mut App) -> anyhow::Result<()> + Send>;

impl App {
    pub(crate) fn remote_manifest_complete(&self) -> bool {
        self.review_live_diff()
            .remote_snapshot
            .as_ref()
            .is_none_or(|s| {
                s["acquisition"] != "patch-v1"
                    || s["streamState"] == "complete"
                        && self
                            .remote_validation
                            .as_ref()
                            .is_some_and(|proof| proof.matches(s))
            })
    }

    pub(crate) fn remote_manifest_status(&self) -> Option<String> {
        let snapshot = self.multi_diff.remote_snapshot.as_ref()?;
        if snapshot["acquisition"] != "patch-v1" {
            return None;
        }
        match snapshot["streamState"].as_str() {
            Some("receiving") => Some(format!(
                "files {}/{}; unverified, read-only",
                self.multi_diff.file_count(),
                snapshot["expectedFiles"]
                    .as_u64()
                    .map_or_else(|| "?".to_owned(), |count| count.to_string())
            )),
            Some("failed") => Some("unverified patch; refresh to retry".to_owned()),
            _ if self
                .multi_diff
                .file_is_sparse(self.multi_diff.selected_index) =>
            {
                Some(
                    if self
                        .multi_diff
                        .hydration_failure(self.multi_diff.selected_index)
                        .is_some()
                    {
                        "full contents unavailable; refresh to retry"
                    } else {
                        "loading selected file contents"
                    }
                    .to_owned(),
                )
            }
            _ if self
                .multi_diff
                .has_patch_display(self.multi_diff.selected_index)
                && self.multi_diff.current_file_diff_status()
                    == oyo_core::multi::DiffStatus::Ready =>
            {
                Some("metadata change".to_owned())
            }
            _ if self
                .multi_diff
                .has_patch_display(self.multi_diff.selected_index) =>
            {
                Some("full diff preparing; patch retained".to_owned())
            }
            _ => None,
        }
    }

    pub(crate) fn poll_remote_stream(&mut self) -> bool {
        let Some(mut session) = self.remote_stream.take() else {
            return false;
        };
        self.ensure_content_session(Some(session.content_context()));
        let mut changed = false;
        let mut finished = false;
        let mut batch_bytes = 0usize;
        let mut batch_files = 0usize;
        for _ in 0..4 {
            match session.try_recv() {
                Ok(RemoteEvent::Files(patches)) => {
                    for patch in patches {
                        batch_bytes += patch.byte_len();
                        batch_files += 1;
                        session.append(&mut self.multi_diff, patch);
                    }
                    self.extend_remote_file_slots();
                    changed = true;
                    if batch_bytes >= 4 * 1024 * 1024 || batch_files >= 128 {
                        break;
                    }
                }
                Ok(RemoteEvent::Complete(proof)) => {
                    if let Err(error) = self.install_remote_proof(proof) {
                        if let Some(snapshot) = self.multi_diff.remote_snapshot.as_mut() {
                            snapshot["streamState"] = "failed".into();
                        }
                        self.notify(ToastEvent::SelectionActionFailed(format!(
                            "Snapshot validation failed: {error}"
                        )));
                        finished = true;
                        changed = true;
                        break;
                    }
                    let selected = self.multi_diff.selected_index;
                    let scroll = self.scroll_offset;
                    let horizontal = self.horizontal_scroll;
                    if let Some(configure) = self.pending_remote_review.take() {
                        if let Err(error) = configure(self) {
                            self.review_mode = false;
                            self.pending_remote_review = Some(configure);
                            self.notify(ToastEvent::SelectionActionFailed(format!(
                                "Review setup failed: {error}"
                            )));
                        }
                    }
                    self.multi_diff.selected_index = selected;
                    self.scroll_offset = scroll;
                    self.horizontal_scroll = horizontal;
                    self.needs_scroll_to_active = false;
                    finished = true;
                    changed = true;
                    break;
                }
                Ok(RemoteEvent::Failed(error)) => {
                    self.multi_diff.suspend_remote_prefetch();
                    if let Some(snapshot) = self.multi_diff.remote_snapshot.as_mut() {
                        snapshot["streamState"] = "failed".into();
                    }
                    self.notify(ToastEvent::SelectionActionFailed(format!(
                        "Incomplete remote patch: {error}"
                    )));
                    finished = true;
                    changed = true;
                    break;
                }
                Err(std::sync::mpsc::TryRecvError::Empty) => break,
                Err(std::sync::mpsc::TryRecvError::Disconnected) => {
                    self.multi_diff.suspend_remote_prefetch();
                    if let Some(snapshot) = self.multi_diff.remote_snapshot.as_mut() {
                        snapshot["streamState"] = "failed".into();
                    }
                    self.notify(ToastEvent::SelectionActionFailed(
                        "Remote patch stopped before validation".to_owned(),
                    ));
                    finished = true;
                    changed = true;
                    break;
                }
            }
        }
        if !finished {
            self.remote_stream = Some(session);
        }
        if changed {
            self.mark_diff_changed();
        }
        changed
    }

    fn extend_remote_file_slots(&mut self) {
        let count = self.multi_diff.file_count();
        self.scroll_offsets_step.resize(count, 0);
        self.scroll_offsets_no_step.resize(count, 0);
        self.horizontal_scrolls_step.resize(count, 0);
        self.horizontal_scrolls_no_step.resize(count, 0);
        self.max_line_widths_step.resize(count, 0);
        self.max_line_widths_no_step.resize(count, 0);
        self.no_step_visited.resize(count, false);
        self.files_visited.resize(count, false);
        self.syntax_caches.resize(count, None);
        self.fold_scope_caches.resize(count, None);
        self.step_state_snapshots.resize(count, None);
        self.no_step_state_snapshots.resize(count, None);
        self.file_disk_baseline
            .resize(count, FileDiskStamp::default());
        self.file_disk_changed.resize(count, false);
        self.file_recently_changed_until.resize(count, None);
    }

    fn install_remote_proof(
        &mut self,
        proof: std::sync::Arc<stream::ValidatedSnapshot>,
    ) -> anyhow::Result<()> {
        let mismatches = proof.hydration_mismatches(&self.multi_diff)?;
        let positions = mismatches
            .iter()
            .filter_map(|index| self.capture_file_promotion(*index))
            .collect::<Vec<_>>();
        let rejected = stream::apply_proof(&mut self.multi_diff, &proof)?;
        for index in &rejected {
            if let Some(cache) = self.syntax_caches.get_mut(*index) {
                *cache = None;
            }
            if let Some(cache) = self.fold_scope_caches.get_mut(*index) {
                *cache = None;
            }
            if let Some(width) = self.max_line_widths_step.get_mut(*index) {
                *width = 0;
            }
            if let Some(width) = self.max_line_widths_no_step.get_mut(*index) {
                *width = 0;
            }
        }
        for position in positions {
            self.restore_file_promotion(position);
        }
        if !rejected.is_empty() {
            self.clear_fold_context_caches();
            self.syntax_scope_cache = None;
            self.mark_diff_changed();
            self.notify(ToastEvent::SelectionActionFailed("Full contents did not match the snapshot. The readable patch is retained; refresh to retry.".to_owned()));
        }
        self.remote_validation = Some(proof);
        Ok(())
    }

    pub(crate) fn ensure_remote_content(&mut self, index: usize) -> bool {
        if !self.multi_diff.ensure_full_content(index) {
            return false;
        }
        self.start_content_loading();
        true
    }

    pub(crate) fn request_remote_content(&mut self, index: usize) -> bool {
        if !self.multi_diff.request_full_content(index) {
            return false;
        }
        self.start_content_loading();
        true
    }

    pub(crate) fn require_selected_complete_content(&mut self) -> bool {
        let index = self.multi_diff.selected_index;
        if !self.multi_diff.file_is_sparse(index) {
            return true;
        }
        self.request_remote_content(index);
        self.notify(ToastEvent::SelectionActionFailed(
            "Loading full contents. The patch remains readable.".to_owned(),
        ));
        false
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::{TopbarTabContent, ViewMode};
    use oyo_core::multi::ContentSource;
    use oyo_core::patch::PatchRecord;
    use oyo_core::AnimationFrame;
    use oyo_core::MultiFileDiff;
    use serde_json::json;

    fn sparse_app() -> App {
        let patch=PatchRecord::parse(b"diff --git a/f b/f\nindex 1111111..2222222 100644\n--- a/f\n+++ b/f\n@@ -40 +40 @@\n-old\n+new\n@@ -80 +80 @@\n-before\n+after\n".to_vec()).unwrap();
        let mut diff = MultiFileDiff::from_pending_files(None, Vec::new(), true);
        diff.append_patch(patch, ContentSource::Empty, ContentSource::Empty);
        diff.remote_snapshot = Some(
            json!({"acquisition":"patch-v1","streamState":"receiving","expectedFiles":2,"target":"https://github.com/o/r/pull/1","provider":"github"}),
        );
        let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
        app.stepping = false;
        app.multi_diff.current_navigator().goto_end();
        app
    }

    #[test]
    fn a_json_completion_flag_does_not_authorize_review_or_sync() {
        let mut app = sparse_app();
        assert!(!app.remote_manifest_complete());
        app.multi_diff.remote_snapshot.as_mut().unwrap()["streamState"] = "complete".into();
        assert!(!app.remote_manifest_complete());
        app.enable_review_mode();
        assert!(!app.review_mode);
        assert!(crate::remote_sync::RemoteReviewContext::from_app(&app).is_err());
    }

    #[test]
    fn selected_nonzero_file_owns_initial_tab_and_append_keeps_state() {
        let mut diff = MultiFileDiff::from_file_pairs(vec![
            ("zero".into(), "a".into(), "b".into()),
            ("one".into(), "a".into(), "c".into()),
        ]);
        diff.selected_index = 1;
        let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
        assert_eq!(app.topbar_tabs[0].content, TopbarTabContent::File(1));
        app.scroll_offsets_no_step[1] = 17;
        app.files_visited[1] = true;
        app.extend_remote_file_slots();
        assert_eq!(app.scroll_offsets_no_step[1], 17);
        assert!(app.files_visited[1]);
        assert_eq!(app.multi_diff.selected_index, 1);
    }

    #[test]
    fn hydration_restores_latest_logical_position_across_changed_diff_shape() {
        let mut app = sparse_app();
        let view = app.current_view_with_frame(AnimationFrame::Idle);
        let row = view
            .iter()
            .position(|line| line.old_line == Some(80))
            .unwrap();
        app.scroll_offset = row;
        app.horizontal_scroll = 7;
        let id = app
            .multi_diff
            .current_navigator()
            .diff()
            .changes
            .iter()
            .find(|c| c.spans[0].old_line == Some(80))
            .unwrap()
            .id;
        app.multi_diff
            .current_navigator()
            .set_cursor_hunk(1, Some(id));
        let position = app.capture_file_promotion(0).unwrap();
        let old = (1..=100)
            .map(|n| {
                if n == 40 {
                    "old".to_owned()
                } else if n == 80 {
                    "before".to_owned()
                } else {
                    format!("line {n}")
                }
            })
            .collect::<Vec<_>>()
            .join("\n")
            + "\n";
        let new = old.replace("old\n", "new\n").replace("before\n", "after\n");
        assert!(app.multi_diff.request_full_content(0));
        app.multi_diff.take_pending_content();
        assert!(app.multi_diff.apply_prepared_content(
            0,
            MultiFileDiff::prepare_remote_file_content(old.into_bytes(), new.into_bytes())
        ));
        app.restore_file_promotion(position);
        assert_eq!(app.horizontal_scroll, 7);
        let nav = app.multi_diff.current_navigator();
        let cursor = nav.state().cursor_change.unwrap();
        assert!(nav
            .diff()
            .changes
            .iter()
            .find(|c| c.id == cursor)
            .unwrap()
            .spans
            .iter()
            .any(|s| s.old_line == Some(80)));
        let view = app.current_view_with_frame(AnimationFrame::Idle);
        assert_eq!(view[app.scroll_offset].old_line, Some(80));
        assert!(view
            .iter()
            .all(|line| line.kind != oyo_core::LineKind::Unloaded));
    }

    #[test]
    fn sparse_views_contain_only_patch_rows_including_real_blank_lines() {
        for mode in [ViewMode::UnifiedPane, ViewMode::Split] {
            let patch = PatchRecord::parse(b"diff --git a/f b/f\nindex 1111111..2222222 100644\n--- a/f\n+++ b/f\n@@ -39,2 +39,2 @@\n \n-old\n+new\n@@ -80 +80 @@\n-before\n+after\n".to_vec()).unwrap();
            let mut diff = MultiFileDiff::from_pending_files(None, Vec::new(), true);
            diff.append_patch(patch, ContentSource::Empty, ContentSource::Empty);
            let mut app = App::new(diff, mode, 0, false, None);
            app.stepping = false;
            app.multi_diff.current_navigator().goto_end();
            let view = app.current_view_with_frame(AnimationFrame::Idle);
            let rows = view
                .iter()
                .map(|line| (line.old_line, line.new_line, line.content.as_str()))
                .collect::<Vec<_>>();
            assert_eq!(
                rows,
                vec![
                    (Some(39), Some(39), ""),
                    (Some(40), None, "old"),
                    (None, Some(40), "new"),
                    (Some(80), None, "before"),
                    (None, Some(80), "after"),
                ],
                "{mode:?}"
            );
        }
    }
}