oyo 0.1.66

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 {}/{} incomplete",
                self.multi_diff.file_count(),
                snapshot["expectedFiles"].as_u64().unwrap_or(0)
            )),
            Some("failed") => Some("incomplete patch; refresh to retry".to_owned()),
            _ if self
                .multi_diff
                .file_is_sparse(self.multi_diff.selected_index) =>
            {
                Some("patch context; F loads full 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;
        };
        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)) => {
                    stream::apply_proof(&mut self.multi_diff, &proof);
                    self.remote_validation = Some(proof);
                    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)) => {
                    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) => {
                    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);
    }

    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
    }
}

pub(super) fn with_unloaded_context(view: Vec<oyo_core::ViewLine>) -> Vec<oyo_core::ViewLine> {
    use oyo_core::{LineKind, ViewLine, ViewSpan, ViewSpanKind};
    let mut result = Vec::with_capacity(view.len() + 8);
    let mut old = 0usize;
    let mut new = 0usize;
    let gap = |next: usize| ViewLine {
        content: "Unloaded context. Press F to fetch full contents.".to_owned(),
        spans: vec![ViewSpan {
            text: "Unloaded context. Press F to fetch full contents.".to_owned(),
            kind: ViewSpanKind::Equal,
        }],
        kind: LineKind::Unloaded,
        old_line: None,
        new_line: None,
        is_active: false,
        is_active_change: false,
        is_primary_active: false,
        show_hunk_extent: false,
        change_id: next,
        hunk_index: None,
        has_changes: false,
    };
    for line in view {
        if line.old_line.is_some_and(|n| n > old.saturating_add(1))
            || line.new_line.is_some_and(|n| n > new.saturating_add(1))
        {
            result.push(gap(line.change_id));
        }
        if let Some(n) = line.old_line {
            old = n;
        }
        if let Some(n) = line.new_line {
            new = n;
        }
        result.push(line);
    }
    if let Some(last) = result.last() {
        if old > 0 || new > 0 {
            result.push(gap(last.change_id));
        }
    }
    result
}

#[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 unloaded_context_has_no_original_line_or_blame_location() {
        let mut app = sparse_app();
        let view = app.current_view_with_frame(AnimationFrame::Idle);
        let gaps = view
            .iter()
            .filter(|line| line.kind == oyo_core::LineKind::Unloaded)
            .collect::<Vec<_>>();
        assert!(!gaps.is_empty());
        assert!(gaps
            .iter()
            .all(|line| line.old_line.is_none() && line.new_line.is_none() && !line.has_changes));
        assert!(view.iter().any(|line| line.old_line == Some(40)));
        assert!(view.iter().any(|line| line.old_line == Some(80)));
    }
}