use super::*; use crate::editor::{Editor, Effect, Mode}; std::thread_local! { pub(super) static WIDTH_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; } #[test] fn conflict_history_preserves_source_gaps_base_changes_and_native_eof_rows() { let history = HistoryComparison::new([ "xbc def\nkeep\n", "abc def\nkeep\n", "abc deg\nadded\nkeep\n", ]); let rows: Vec<_> = (0..history.row_count()) .map(|row| { std::array::from_fn::<_, 3, _>(|column| history.column(column).view().native_row(row)) }) .collect(); assert_eq!( rows, [ [Some(0), Some(0), Some(0)], [None, None, Some(1)], [Some(1), Some(1), Some(2)], [Some(2), Some(2), Some(3)], ] ); for column in 0..3 { let history_column = history.column(column); let projection = history_column.view(); for (display, row) in rows.iter().enumerate() { if let Some(source) = row[column] { assert_eq!(projection.display_row(source), Some(display)); } } } let base = history.column(1); let base = base.view(); let ColumnRow::Text(line) = base.row(0).unwrap() else { panic!("changed base row must contain text"); }; assert!(line.changed); for column in [0, 6] { assert!(line.changes().any(|range| range.contains(&column))); } assert!(!line.changes().any(|range| range.contains(&4))); let ColumnRow::Text(line) = base.row(2).unwrap() else { panic!("unchanged base row must contain text"); }; assert!(!line.changed); assert_eq!(line.changes().count(), 0); let right = history.column(2); let ColumnRow::Text(line) = right.view().row(1).unwrap() else { panic!("inserted right row must contain text"); }; assert_eq!(line.text, Some("added")); assert!(matches!(base.row(1), Some(ColumnRow::Gap))); // Rebuilding for a new input/base choice must not retain old source mappings. let history = HistoryComparison::new([ "abc def\nkeep\n", "abc deg\nadded\nkeep\n", "xbc def\nkeep\n", ]); assert_eq!(history.column(1).view().native_row(1), Some(1)); assert_eq!(history.column(0).view().native_row(1), None); assert_eq!(history.column(2).view().native_row(1), None); for text in ["", "\n", "same", "same\n"] { let history = HistoryComparison::new([text; 3]); let native_rows = text.split('\n').count(); assert_eq!(history.row_count(), native_rows); for column in 0..3 { for row in 0..native_rows { assert_eq!(history.column(column).view().native_row(row), Some(row)); assert_eq!(history.column(column).view().display_row(row), Some(row)); } } } } #[test] fn resolver_equal_text_retains_native_rows_and_cached_viewport() { for text in ["", "\n", "same\n", "same"] { let lines: Vec<_> = text.split('\n').map(str::to_owned).collect(); let mut state = DiffState::new(); state.set_resolver_text(&lines, &lines); assert_eq!(state.pane_layout(), PaneLayout::Split); assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty()); assert_eq!( state.comparison.as_ref().unwrap().files[0].path, Path::new("resolution.txt") ); assert_eq!(state.files.as_ref().unwrap().entries().len(), 1); for row in 0..lines.len() { assert!(state.source_display_row(Focus::Before, row).is_some()); assert!(state.source_display_row(Focus::After, row).is_some()); } let id = state.comparison_id; state.top = 1; state.left_before = 3; state.left_after = 4; state.set_resolver_text(&lines, &lines); assert_eq!(state.comparison_id, id); assert_eq!((state.top, state.left_before, state.left_after), (1, 3, 4)); } } #[test] fn resolver_catalog_reveals_the_current_native_file() { let mut state = DiffState::new(); state.set_resolver_paths( PathBuf::new(), vec!["binary.bin".into(), "first.txt".into(), "second.txt".into()], 1, ); let files = state.files.as_ref().unwrap(); assert_eq!( files.entries().nth(files.selected).unwrap().path, Path::new("first.txt") ); assert!(!files.is_conflicted(Path::new("first.txt"))); state .files .as_mut() .unwrap() .set_conflicted(Path::new("first.txt"), true); state.set_resolver_paths( PathBuf::new(), vec!["binary.bin".into(), "first.txt".into(), "second.txt".into()], 2, ); let files = state.files.as_ref().unwrap(); assert_eq!( files.entries().nth(files.selected).unwrap().path, Path::new("second.txt") ); assert!(files.is_conflicted(Path::new("first.txt"))); assert!(!files.is_conflicted(Path::new("second.txt"))); } fn excerpt_state(len: usize, changed: &[usize]) -> DiffState { let mut state = DiffState::new(); state.phase = Phase::View; state.focus = Focus::Before; state.rows = (0..len) .map(|index| AlignedRow { before: Some(index), after: Some(index + 3), kind: if changed.contains(&index) { RowKind::Changed } else { RowKind::Equal }, changes: Vec::new(), }) .collect(); state.reset_excerpts(); state } #[test] fn excerpts_cover_context_and_hidden_rows_once_without_reindexing() { let state = excerpt_state(100, &[30, 70]); assert_eq!(state.excerpt_ranges, [(20, 41), (60, 81)]); assert_eq!( state.display_rows[0], DisplayRow::Fold { start: 0, end: 20 } ); assert_eq!( state.display_rows[22], DisplayRow::Fold { start: 41, end: 60 } ); assert_eq!( state.display_rows[44], DisplayRow::Fold { start: 81, end: 100 } ); let mut covered = Vec::new(); for row in &state.display_rows { match *row { DisplayRow::Line(index) => { covered.push(index); assert_eq!(state.rows[index].before, Some(index)); assert_eq!(state.rows[index].after, Some(index + 3)); } DisplayRow::Fold { start, end } => { assert!( state.rows[start..end] .iter() .all(|row| row.kind == RowKind::Equal) ); covered.extend(start..end); } } } assert_eq!(covered, (0..100).collect::>()); for index in [30, 70] { assert_eq!( state .display_rows .iter() .filter(|&&row| row == DisplayRow::Line(index)) .count(), 1 ); } } #[test] fn zero_context_boundaries_adjacent_changes_and_touching_ranges() { let mut state = excerpt_state(60, &[0, 1, 30, 59]); state.set_context_options(0, 15); assert_eq!(state.excerpt_ranges, [(0, 2), (30, 31), (59, 60)]); assert_eq!( state.display_rows, [ DisplayRow::Line(0), DisplayRow::Line(1), DisplayRow::Fold { start: 2, end: 30 }, DisplayRow::Line(30), DisplayRow::Fold { start: 31, end: 59 }, DisplayRow::Line(59), ] ); assert_eq!(excerpt_state(60, &[20, 41]).excerpt_ranges, [(10, 52)]); assert_eq!( excerpt_state(3, &[0, 1, 2]).display_rows, [ DisplayRow::Line(0), DisplayRow::Line(1), DisplayRow::Line(2), ] ); } #[test] fn expansion_anchors_source_rows_merges_and_clamps_both_directions() { let mut state = excerpt_state(100, &[30, 70]); state.top = 11; state.handle(Input::Char('[')); assert_eq!(state.excerpt_ranges, [(5, 41), (60, 81)]); assert_eq!(state.display_rows[state.top], DisplayRow::Line(30)); state.handle(Input::Char('[')); assert_eq!(state.excerpt_ranges, [(0, 41), (60, 81)]); state.handle(Input::Char(']')); assert_eq!(state.excerpt_ranges, [(0, 56), (60, 81)]); state.handle(Input::Char(']')); assert_eq!(state.excerpt_ranges, [(0, 81)]); assert_eq!(state.display_rows[state.top], DisplayRow::Line(30)); state.handle(Input::Char(']')); assert_eq!(state.excerpt_ranges, [(0, 96)]); state.handle(Input::Char(']')); state.handle(Input::Char(']')); assert_eq!(state.excerpt_ranges, [(0, 100)]); assert_eq!(state.display_rows[state.top], DisplayRow::Line(30)); } #[test] fn folds_choose_following_excerpt_or_previous_at_end() { let mut state = excerpt_state(100, &[30, 70]); state.handle(Input::Char('[')); assert_eq!(state.display_rows[state.top], DisplayRow::Line(5)); state.top = state .display_rows .iter() .position(|&row| row == (DisplayRow::Fold { start: 41, end: 60 })) .unwrap(); state.handle(Input::Char('[')); assert_eq!(state.excerpt_ranges, [(5, 41), (45, 81)]); assert_eq!(state.display_rows[state.top], DisplayRow::Line(45)); state.handle(Input::End); state.handle(Input::Char(']')); assert_eq!(state.excerpt_ranges, [(5, 41), (45, 96)]); assert_eq!(state.display_rows[state.top], DisplayRow::Line(81)); } #[test] fn navigation_uses_display_length_and_custom_increment() { let mut state = excerpt_state(100, &[30]); state.handle(Input::End); state.handle(Input::Down); assert_eq!(state.top, 22); state.handle(Input::Up); assert_eq!(state.top, 21); state.handle(Input::Home); state.handle(Input::Up); assert_eq!(state.top, 0); state.set_context_options(0, 2); state.handle(Input::Char('[')); assert_eq!(state.excerpt_ranges, [(28, 31)]); assert_eq!(state.display_rows[state.top], DisplayRow::Line(28)); state.handle(Input::Char(']')); assert_eq!(state.excerpt_ranges, [(28, 33)]); } #[test] fn metadata_empty_and_wholly_added_removed_files_preserve_alignment() { let mut state = DiffState::new(); let mut comparison = comparison(); comparison.files[0].hunks.clear(); comparison.files[0].after = comparison.files[0].before.clone(); state.comparison = Some(comparison); state.align_file(); assert!(state.excerpt_ranges.is_empty()); assert!(state.display_rows.is_empty()); assert_eq!(state.rows.len(), 5); state.file = 1; state.align_file(); assert_eq!( state.display_rows, [DisplayRow::Line(0), DisplayRow::Line(1)] ); assert!( state .rows .iter() .all(|row| row.kind == RowKind::Added && row.before.is_none()) ); let file = &mut state.comparison.as_mut().unwrap().files[1]; std::mem::swap(&mut file.before, &mut file.after); file.hunks[0].before_len = 2; file.hunks[0].after_len = 0; state.align_file(); assert_eq!( state.display_rows, [DisplayRow::Line(0), DisplayRow::Line(1)] ); assert!( state .rows .iter() .all(|row| row.kind == RowKind::Removed && row.after.is_none()) ); state.file = 2; state.align_file(); assert!(state.rows.is_empty()); assert!(state.display_rows.is_empty()); assert!(state.comparison.as_ref().unwrap().files[2].notice.is_some()); } fn revision( target: RevisionTarget, label: &str, description: &str, category: Category, ) -> Revision { Revision { target, label: label.into(), description: description.into(), category, } } fn catalog() -> Catalog { Catalog { kind: RepoKind::Git, entries: vec![ revision( RevisionTarget::Working, "Working checkout", "", Category::Commits, ), revision( RevisionTarget::Commit("abcdef123".into()), "abcdef123", "Fix 界 e\u{301}", Category::Commits, ), revision( RevisionTarget::Commit("fedcba321".into()), "fedcba321", "Another change", Category::Commits, ), revision( RevisionTarget::FetchRemote("origin".into()), "Load history from origin", "", Category::Commits, ), revision( RevisionTarget::Commit("branch-tip".into()), "topic/branch", "", Category::Branches, ), revision( RevisionTarget::Remote { remote: "origin".into(), reference: "refs/heads/topic".into(), }, "topic@origin", "", Category::Branches, ), revision( RevisionTarget::Commit("tag-tip".into()), "release", "", Category::Tags, ), ], notice: None, } } fn pull_page(closed: bool, rows: &[(u64, &str, &str)], has_more: bool) -> Outcome { Outcome::PullRequestPage { closed, result: Ok(PullRequestPage { kind: RepoKind::Git, remote: "origin".into(), has_more, entries: rows .iter() .map(|&(number, title, date)| { ( revision( RevisionTarget::PullRequest { remote: "origin".into(), number, }, &format!("#{number} {title}"), "Alice · main ← topic", if closed { Category::Closed } else { Category::Open }, ), date.into(), ) }) .collect(), }), } } #[test] fn pull_request_categories_keep_query_and_select_loaded_rows_before_completion() { let mut state = DiffState::pull_requests("origin".into()); assert_eq!( state.categories(), [Category::Open, Category::Closed, Category::All] ); state.take_request(); state.handle(Input::Char('\t')); for ch in "FIX üni Alice".chars() { state.handle(Input::Char(ch)); } state.apply_result( 1, Ok(pull_page( true, &[(2, "Fix Ünicode", "2026-01-02T00:00:00Z")], true, )), ); assert!(state.loading); assert!(state.pull_request_partial()); assert_eq!(state.entries().next().unwrap().label, "#2 Fix Ünicode"); state.handle(Input::Char('\t')); state.apply_result( 1, Ok(pull_page( false, &[ (1, "Fix Ünicode", "2026-01-03T00:00:00Z"), (3, "Other work", "2026-01-01T00:00:00Z"), ], true, )), ); assert_eq!( state .entries() .map(|entry| entry.label.as_str()) .collect::>(), ["#1 Fix Ünicode", "#2 Fix Ünicode"] ); state.handle(Input::Char('\t')); assert_eq!(state.query, "FIX üni Alice"); state.comparison_mode = ComparisonMode::Snapshots; state.handle(Input::Enter); assert_eq!(state.phase, Phase::After); assert!(state.before.is_none() && state.after.is_none()); assert_eq!(state.comparison_mode, ComparisonMode::BranchChanges); assert!(matches!( state.take_request(), Some(Operation::ResolvePullRequest(Revision { target: RevisionTarget::PullRequest { number: 1, .. }, .. })) )); } #[test] fn pull_request_all_streams_sort_deduplicate_and_preserve_selection_identity() { let mut state = DiffState::pull_requests("origin".into()); state.take_request(); state.handle(Input::BackTab); state.apply_result( 1, Ok(pull_page( false, &[ (1, "First", "2026-01-01T00:00:00Z"), (2, "Second", "2026-01-02T00:00:00Z"), ], true, )), ); state.handle(Input::End); assert_eq!( state.entries().nth(state.selected).unwrap().label, "#1 First" ); state.apply_result(1, Ok(pull_page(true, &[], true))); assert!(state.pull_request_partial()); state.apply_result( 1, Ok(pull_page( true, &[(3, "Newest", "2026-01-03T00:00:00Z")], true, )), ); assert_eq!( state.entries().nth(state.selected).unwrap().label, "#1 First" ); state.apply_result( 1, Ok(pull_page( false, &[(2, "Second updated", "2026-01-02T00:00:00Z")], false, )), ); assert_eq!(state.entries().len(), 3); assert_eq!( state .entries() .map(|entry| entry.label.as_str()) .collect::>(), ["#3 Newest", "#2 Second updated", "#1 First"] ); assert!(state.pull_request_partial()); state.apply_result(1, Ok(pull_page(true, &[], false))); assert!(!state.pull_request_partial()); assert!(!state.loading); assert_eq!( state.entries().nth(state.selected).unwrap().label, "#1 First" ); } #[test] fn pull_request_late_category_errors_retry_without_losing_rows_or_other_results() { let mut editor = editor(); editor.execute("pull_requests origin"); let (id, _) = editor.take_diff_request().unwrap(); editor.apply_diff_result( id, Ok(pull_page( false, &[(1, "Open", "2026-01-01T00:00:00Z")], true, )), ); editor.apply_diff_result( id, Ok(pull_page( true, &[(2, "Closed", "2026-01-02T00:00:00Z")], true, )), ); editor.handle(Input::Char('\t')); editor.apply_diff_result( id, Ok(Outcome::PullRequestPage { closed: true, result: Err("offline".into()), }), ); let diff = editor.diff().unwrap(); assert!(diff.pull_request_notice().unwrap().contains("offline")); assert_eq!(diff.entries().next().unwrap().label, "#2 Closed"); assert!(diff.pull_request_partial()); editor.handle(Input::Ctrl('r')); assert_eq!(editor.diff().unwrap().pull_request_retries, [0, 1]); assert!(editor.take_diff_request().is_none()); editor.apply_diff_result( id, Ok(pull_page( false, &[(3, "More open", "2026-01-03T00:00:00Z")], false, )), ); assert_eq!( editor.diff().unwrap().entries().next().unwrap().label, "#2 Closed" ); editor.apply_diff_result(id, Ok(pull_page(true, &[], false))); assert!(!editor.diff().unwrap().pull_request_partial()); editor.handle(Input::Enter); let (_, operation) = editor.take_diff_request().unwrap(); assert!(matches!(operation, Operation::ResolvePullRequest(_))); assert!(!editor.apply_diff_result(id, Ok(pull_page(false, &[], false)))); } #[test] fn pull_request_loading_retry_escape_and_reopen_reject_stale_results() { let mut editor = editor(); editor .apply(vec![Effect::Replace("unsaved source".into())]) .unwrap(); editor.execute("pull_requests origin"); let (first, _) = editor.take_diff_request().unwrap(); editor.handle(Input::Enter); assert!(editor.take_diff_request().is_none()); text(&mut editor, "alice"); editor.apply_diff_result( first, Ok(Outcome::PullRequestPage { closed: false, result: Err("API unavailable".into()), }), ); editor.handle(Input::Enter); assert_eq!(editor.diff().unwrap().pull_request_retries, [1, 0]); assert!(editor.take_diff_request().is_none()); assert!(editor.apply_diff_result( first, Ok(pull_page( false, &[ (1, "Open", "2026-01-02T00:00:00Z"), (2, "Other open", "2026-01-01T00:00:00Z"), ], true )) )); editor.handle(Input::Enter); let (resolve, operation) = editor.take_diff_request().unwrap(); assert!(matches!(operation, Operation::ResolvePullRequest(_))); assert!(!editor.apply_diff_result(first, Ok(pull_page(true, &[], false)))); editor.apply_diff_result(resolve, Err("Historical head no longer retained".into())); assert!(editor.diff().unwrap().before.is_none()); assert!(editor.diff().unwrap().after.is_none()); editor.handle(Input::Down); editor.handle(Input::Enter); let (retry, operation) = editor.take_diff_request().unwrap(); assert!(matches!( operation, Operation::ResolvePullRequest(Revision { target: RevisionTarget::PullRequest { number: 2, .. }, .. }) )); editor.handle(Input::Escape); assert!(editor.diff().is_none()); assert!(!editor.apply_diff_result(retry, Ok(Outcome::Comparison(comparison())))); assert_eq!(editor.lines(), ["unsaved source"]); assert!(editor.dirty()); editor.execute("pull_requests"); let (reopened, _) = editor.take_diff_request().unwrap(); assert!(!editor.apply_diff_result(first, Ok(pull_page(false, &[], false)))); assert!(editor.apply_diff_result(reopened, Ok(pull_page(false, &[], false)))); assert!(editor.apply_diff_result(reopened, Ok(pull_page(true, &[], false)))); editor.handle(Input::Enter); assert!(editor.take_diff_request().is_none()); } #[test] fn resolved_pull_request_modes_reuse_pinned_endpoints_without_resolving_again() { let mut state = DiffState::pull_requests("origin".into()); state.take_request(); state.apply_result( 1, Ok(pull_page( false, &[(1, "Open", "2026-01-01T00:00:00Z")], true, )), ); state.handle(Input::Enter); state.take_request(); let before = revision( RevisionTarget::RemoteCommit { remote: "origin".into(), reference: "refs/heads/main".into(), id: "base-sha".into(), }, "main", "", Category::Open, ); let after = revision( RevisionTarget::RemoteCommit { remote: "origin".into(), reference: "refs/pull/1/head".into(), id: "head-sha".into(), }, "#1 Open", "", Category::Open, ); state.apply_result( 2, Ok(Outcome::ResolvedPullRequest { before: before.clone(), after: after.clone(), }), ); assert!(matches!( state.take_request(), Some(Operation::Compare { mode: ComparisonMode::BranchChanges, .. }) )); state.apply_result(3, Err("No common ancestor".into())); assert_eq!(state.before.as_ref(), Some(&before)); assert_eq!(state.after.as_ref(), Some(&after)); state.toggle_comparison_mode().unwrap(); let Some(Operation::Compare { before: pinned_before, after: pinned_after, mode, .. }) = state.take_request() else { panic!("expected ordinary pinned comparison"); }; assert_eq!( (pinned_before, pinned_after, mode), (before, after, ComparisonMode::Snapshots) ); } fn editor() -> Editor { Editor::open(None, vec![]).unwrap() } fn open(editor: &mut Editor) { assert!(editor.execute("diff").is_none()); let (id, operation) = editor.take_diff_request().unwrap(); assert!(matches!(operation, Operation::Discover)); assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(catalog())))); } fn text(editor: &mut Editor, text: &str) { for ch in text.chars() { editor.handle(Input::Char(ch)); } } fn choose_pair(editor: &mut Editor) -> u64 { editor.handle(Input::Down); editor.handle(Input::Enter); assert_eq!(editor.diff().unwrap().phase, Phase::After); editor.handle(Input::Enter); let (id, operation) = editor.take_diff_request().unwrap(); match operation { Operation::Compare { before, after, .. } => { assert_eq!(before.target, RevisionTarget::Commit("abcdef123".into())); assert_eq!(after.target, RevisionTarget::Working); } _ => panic!("expected compare"), } id } fn comparison() -> Comparison { Comparison { revisions: ["abcdef123".into(), "working:snapshot".into()], before: "abcdef123".into(), after: "Working checkout".into(), root: PathBuf::new(), editable_paths: BTreeSet::new(), files: vec![ ChangedFile { path: "renamed.txt".into(), old_path: Some("old.txt".into()), status: FileStatus::Renamed, loaded: true, before: vec![ "same".into(), "old".into(), "tail".into(), "deleted".into(), "end".into(), ], after: vec![ "same".into(), "new".into(), "extra".into(), "tail".into(), "end".into(), ], before_eof: false, after_eof: false, hunks: vec![ Hunk { before_start: 1, before_len: 1, after_start: 1, after_len: 2, }, Hunk { before_start: 3, before_len: 1, after_start: 4, after_len: 0, }, ], notice: None, }, ChangedFile { path: "added.txt".into(), old_path: None, status: FileStatus::Added, loaded: true, before: vec![], after: vec!["a".into(), "b".into()], before_eof: false, after_eof: false, hunks: vec![Hunk { before_start: 0, before_len: 0, after_start: 0, after_len: 2, }], notice: None, }, ChangedFile { path: "removed.bin".into(), old_path: None, status: FileStatus::Removed, loaded: true, before: vec![], after: vec![], before_eof: false, after_eof: false, hunks: vec![], notice: Some("Binary file".into()), }, ], } } #[test] fn status_layout_cycles_only_present_panes_and_normalizes_selection() { let mut state = live_state(vec!["old".into()], vec!["new".into()], true); state.comparison = Some(comparison()); state.files = Some(FilePicker::from_paths( PathBuf::new(), [ (0, "renamed.txt".into()), (1, "added.txt".into()), (2, "removed.bin".into()), ], )); for (path, layout, first, last) in [ ( "renamed.txt", PaneLayout::Split, Focus::Before, Focus::After, ), ("added.txt", PaneLayout::After, Focus::After, Focus::After), ( "removed.bin", PaneLayout::Before, Focus::Before, Focus::Before, ), ] { state.files_visible = true; state.focus = Focus::Files; assert!(state.select_file(Path::new(path))); assert_eq!(state.pane_layout(), layout); state.handle(Input::Char('\t')); assert_eq!(state.focus, first); state.handle(Input::BackTab); assert_eq!(state.focus, Focus::Files); state.handle(Input::BackTab); assert_eq!(state.focus, last); state.handle(Input::Char('\t')); assert_eq!(state.focus, Focus::Files); state.files_visible = false; state.normalize_focus(); assert_eq!(state.focus, first); state.handle(Input::BackTab); assert_eq!(state.focus, last); state.handle(Input::Char('\t')); assert_eq!(state.focus, first); } state.focus = Focus::Before; assert!(state.select_file(Path::new("added.txt"))); assert_eq!(state.focus, Focus::After); assert!(state.select_file(Path::new("removed.bin"))); assert_eq!(state.focus, Focus::Before); assert!(state.select_file(Path::new("renamed.txt"))); assert_eq!(state.focus, Focus::Before); } #[test] fn rename_layout_and_editability_include_eof_changes() { let mut state = live_state(vec!["same".into()], vec!["same".into()], true); state.comparison.as_mut().unwrap().files[0].status = FileStatus::Renamed; assert_eq!(state.pane_layout(), PaneLayout::Empty); assert!(!state.after_editable()); state.normalize_focus(); assert_eq!(state.focus, Focus::Before); state.files_visible = false; for input in [Input::Char('\t'), Input::BackTab] { state.handle(input); assert_eq!(state.focus, Focus::Before); } state.comparison.as_mut().unwrap().files[0].after_eof = false; assert_eq!(state.pane_layout(), PaneLayout::Split); assert!(state.after_editable()); state.comparison.as_mut().unwrap().files[0].after[0] = "changed".into(); assert!(state.after_editable()); state.comparison.as_mut().unwrap().files[0].status = FileStatus::Removed; assert!(!state.after_editable()); state.comparison.as_mut().unwrap().files[0].status = FileStatus::Added; assert!(state.after_editable()); let file = &mut state.comparison.as_mut().unwrap().files[0]; file.before.clear(); file.after.clear(); file.notice = Some("empty file".into()); state.normalize_focus(); assert_eq!(state.pane_layout(), PaneLayout::After); assert_eq!(state.focus, Focus::After); assert!(state.after_editable()); assert_eq!( state.comparison.as_ref().unwrap().files[0] .notice .as_deref(), Some("empty file") ); } #[test] fn opening_diff_file_focuses_after_when_present() { for view_mode in [ViewMode::Split, ViewMode::Unified] { for presentation in [FilePresentation::Docked, FilePresentation::Floating] { for status in [ FileStatus::Modified, FileStatus::Renamed, FileStatus::Added, FileStatus::Removed, ] { let mut editor = editor(); editor.set_diff_display_options(view_mode, crate::panes::SplitAxis::Vertical); open(&mut editor); let id = choose_pair(&mut editor); let mut changes = comparison(); let index = match status { FileStatus::Modified | FileStatus::Renamed => 0, FileStatus::Added => 1, FileStatus::Removed => 2, }; let mut file = changes.files.remove(index); file.status = status; let expected_text = file.after.join("\n"); changes.files = vec![file]; editor.apply_diff_result(id, Ok(Outcome::Comparison(changes))); if presentation == FilePresentation::Floating { text(&mut editor, " o"); } editor.handle(Input::Enter); let diff = editor.diff().unwrap(); assert_eq!( diff.focus, if status == FileStatus::Removed { Focus::Before } else { Focus::After }, "{view_mode:?} {presentation:?} {status:?}", ); assert!(!diff.files.as_ref().unwrap().focused); assert_eq!(diff.files_visible, presentation == FilePresentation::Docked); if status != FileStatus::Removed { assert_eq!(editor.context().text, expected_text); } } } } } #[test] fn added_file_picker_cancel_close_and_mouse_focus_use_after() { let mut editor = editor(); open(&mut editor); let id = choose_pair(&mut editor); let mut changes = comparison(); changes.files = vec![changes.files.remove(1)]; editor.apply_diff_result(id, Ok(Outcome::Comparison(changes))); editor.handle(Input::Escape); assert_eq!(editor.diff().unwrap().focus, Focus::After); text(&mut editor, " e"); editor.handle(Input::Ctrl('w')); assert_eq!(editor.diff().unwrap().focus, Focus::After); assert!(!editor.diff().unwrap().files_visible); editor.scroll_diff(Focus::Before, true); assert_eq!(editor.diff().unwrap().focus, Focus::After); editor.handle(Input::BackTab); assert_eq!(editor.diff().unwrap().focus, Focus::After); } #[test] fn revision_matches_rank_later_contiguous_boundaries_and_tight_subsequences() { assert_eq!(fuzzy_match("r---f---s/topic/rfs", "rfs").unwrap()[0], 2); assert!(fuzzy_match("topic/rfs", "rfs") < fuzzy_match("topicxrfs", "rfs")); assert!(fuzzy_match("r-f-s", "rfs") < fuzzy_match("r---f---s", "rfs")); assert!(fuzzy_match("über-parser", "üpr").is_some()); assert!(fuzzy_match("parser", "srp").is_none()); } #[test] fn jujutsu_fuzzy_selectors_rank_labels_above_descriptions_in_both_stages() { for phase in [Phase::Before, Phase::After] { for (category_index, &category) in RepoKind::Jujutsu.categories().iter().enumerate() { let mut state = DiffState::new(); state.apply_result( 1, Ok(Outcome::Catalog(Catalog { kind: RepoKind::Jujutsu, entries: vec![ revision( RevisionTarget::Working, "Working checkout", "", Category::Revisions, ), revision( RevisionTarget::Commit("description-target".into()), "unrelated", "sm/fin-1205-pilot-assignment-schema", category, ), revision( RevisionTarget::Commit("immutable-fuzzy-target".into()), "sm/fin-1205-pilot-assignment-schema", "", category, ), revision( RevisionTarget::Commit("immutable-exact-target".into()), "f1205pas", "", category, ), ], notice: None, })), ); state.phase = phase; state.category = category_index; state.query = "F1205PAS".into(); state.refilter(); assert_eq!( state .entries() .skip(1) .map(|entry| &entry.target) .collect::>(), [ &RevisionTarget::Commit("immutable-exact-target".into()), &RevisionTarget::Commit("immutable-fuzzy-target".into()), &RevisionTarget::Commit("description-target".into()), ] ); } } } #[test] fn selector_filters_literal_unicode_tokens_and_pins_working_in_each_category() { let mut editor = editor(); open(&mut editor); text(&mut editor, "FIX 界 e\u{301}"); assert_eq!( editor .diff() .unwrap() .entries() .map(|entry| entry.label.as_str()) .collect::>(), ["Working checkout", "abcdef123"] ); editor.handle(Input::Backspace); assert_eq!(editor.diff().unwrap().query, "FIX 界 "); editor.handle(Input::Char('\t')); assert_eq!(editor.diff().unwrap().category, 1); assert_eq!(editor.diff().unwrap().entries().len(), 1); editor.handle(Input::Ctrl('u')); assert_eq!(editor.diff().unwrap().entries().len(), 3); editor.handle(Input::BackTab); assert_eq!(editor.diff().unwrap().category, 0); editor.handle(Input::BackTab); assert_eq!(editor.diff().unwrap().category, 2); assert_eq!(editor.diff().unwrap().entries().len(), 2); editor.handle(Input::Char('\t')); assert_eq!(editor.diff().unwrap().category, 0); editor .bind("selection", "x", Some("core.down".into())) .unwrap(); editor.handle(Input::Char('x')); assert_eq!(editor.diff().unwrap().selected, 1); assert!(editor.diff().unwrap().query.is_empty()); editor.bind("selection", "ctrl+j", None).unwrap(); editor.handle(Input::Ctrl('j')); assert_eq!(editor.diff().unwrap().selected, 1); } #[test] fn selector_word_and_line_deletion_preserve_source_in_both_stages() { for after in [false, true] { let mut editor = editor(); editor .apply(vec![Effect::Replace("source text".into())]) .unwrap(); let source = editor.context().text; let revision = editor.text_revision(); let carets = editor.carets(); open(&mut editor); if after { editor.handle(Input::Enter); } let phase = if after { Phase::After } else { Phase::Before }; text(&mut editor, "FIX missing"); assert_eq!(editor.diff().unwrap().entries().len(), 1); editor.handle(Input::AltBackspace); assert_eq!(editor.diff().unwrap().query, "FIX "); assert_eq!(editor.diff().unwrap().entries().len(), 2); editor.handle(Input::SuperBackspace); assert!(editor.diff().unwrap().query.is_empty()); assert_eq!( editor .diff() .unwrap() .entries() .map(|entry| entry.label.as_str()) .collect::>(), [ "Working checkout", "abcdef123", "fedcba321", "Load history from origin" ] ); for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] { editor.handle(input); } assert_eq!(editor.diff().unwrap().phase, phase); assert_eq!(editor.context().text, source); assert_eq!(editor.text_revision(), revision); assert_eq!(editor.carets(), carets); } } #[test] fn loader_preserves_after_step_query_category_and_selection_then_compares() { let mut editor = editor(); open(&mut editor); editor.handle(Input::Down); editor.handle(Input::Enter); text(&mut editor, "origin"); editor.handle(Input::Down); editor.handle(Input::Enter); let (id, operation) = editor.take_diff_request().unwrap(); assert!(matches!(operation, Operation::FetchRemote(remote) if remote == "origin")); assert_eq!(editor.diff().unwrap().phase, Phase::After); assert!(editor.diff().unwrap().loading); editor.handle(Input::Enter); assert!(editor.take_diff_request().is_none()); let mut loaded = catalog(); loaded.entries.push(revision( RevisionTarget::Commit("remote-commit".into()), "remote-commit", "origin remote history", Category::Commits, )); assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(loaded)))); let state = editor.diff().unwrap(); assert_eq!(state.phase, Phase::After); assert_eq!(state.query, "origin"); assert_eq!(state.category, 0); assert_eq!( state.entries().nth(state.selected).unwrap().target, RevisionTarget::FetchRemote("origin".into()) ); assert_eq!(state.entries().len(), 3); let remote_position = state .entries() .position( |entry| matches!(&entry.target, RevisionTarget::Commit(id) if id == "remote-commit"), ) .unwrap(); editor.handle(Input::Home); for _ in 0..remote_position { editor.handle(Input::Down); } editor.handle(Input::Enter); let (id, operation) = editor.take_diff_request().unwrap(); assert!(matches!(operation, Operation::Compare { after, .. } if after.target == RevisionTarget::Commit("remote-commit".into()))); assert!(editor.apply_diff_result(id, Err("comparison failed".into()))); assert_eq!(editor.diff().unwrap().phase, Phase::After); assert_eq!(editor.diff().unwrap().query, "origin"); assert!(editor.diff().unwrap().error.is_some()); editor.handle(Input::Enter); assert!(matches!( editor.take_diff_request(), Some((_, Operation::Compare { .. })) )); } #[test] fn selector_escape_closes_each_stage_and_rejects_late_results() { for after in [false, true] { let mut editor = editor(); editor .apply(vec![Effect::Replace("unsaved source".into())]) .unwrap(); let buffer = editor.buffer_id(); open(&mut editor); let request = if after { Some(choose_pair(&mut editor)) } else { text(&mut editor, "query"); None }; editor.handle(Input::Escape); assert!(editor.diff().is_none()); assert_eq!(editor.buffer_id(), buffer); assert_eq!(editor.lines(), ["unsaved source"]); assert!(editor.dirty()); if let Some(id) = request { assert!(!editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison())))); assert!(editor.diff().is_none()); } } } #[test] fn selector_printable_input_precedes_normal_bindings_but_not_selection_overrides() { for after in [false, true] { let mut editor = editor(); open(&mut editor); if after { editor.handle(Input::Enter); } editor .bind("normal", "x", Some("core.command".into())) .unwrap(); for ch in ":_| x".chars() { editor.handle(Input::Char(ch)); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.pane_layout().len(), 1); } assert_eq!(editor.diff().unwrap().query, ":_| x"); editor.handle(Input::Ctrl('u')); editor .bind("selection", ":", Some("core.down".into())) .unwrap(); editor.handle(Input::Char(':')); assert_eq!(editor.diff().unwrap().selected, 1); assert!(editor.diff().unwrap().query.is_empty()); } let mut editor = editor(); open(&mut editor); editor.handle(Input::Ctrl('p')); editor.handle(Input::Char(':')); assert_eq!(editor.palette().unwrap().query, ":"); assert_eq!(editor.mode(), Mode::Normal); editor.handle(Input::Escape); assert!(editor.palette().is_none()); assert!(editor.diff().is_some()); } #[test] fn file_to_directory_change_opens_both_historical_paths() { let mut editor = editor(); open(&mut editor); let id = choose_pair(&mut editor); let mut changes = comparison(); changes.files[0].path = "x".into(); changes.files[1].path = "x/y".into(); changes.files.truncate(2); editor.apply_diff_result(id, Ok(Outcome::Comparison(changes))); let picker = editor.diff().unwrap().files.as_ref().unwrap(); assert!(!picker.entries().nth(picker.selected).unwrap().is_dir); editor.handle(Input::Enter); assert_eq!(editor.diff().unwrap().file, 0); text(&mut editor, " e"); editor.handle(Input::Char('i')); text(&mut editor, "x/y"); editor.handle(Input::Enter); assert_eq!(editor.diff().unwrap().file, 1); assert_eq!(editor.diff().unwrap().focus, Focus::After); } #[test] fn changed_tree_filters_navigates_and_opens_without_touching_buffers() { let mut editor = editor(); let original = editor.context().text; let carets = editor.carets(); let revision = editor.text_revision(); open(&mut editor); let id = choose_pair(&mut editor); let mut changes = comparison(); changes.files[0].path = "src/deep/renamed.txt".into(); changes.files[1].path = "src/deep/added.txt".into(); changes.files[2].path = "archive/gone/removed.bin".into(); editor.apply_diff_result(id, Ok(Outcome::Comparison(changes))); let picker = editor.diff().unwrap().files.as_ref().unwrap(); assert!(picker.focused && !picker.searching); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, std::path::Path::new("src/deep/renamed.txt") ); assert!(picker.entries().any(|row| row.label == "src/deep")); editor.bind("tree", "z", Some("core.left".into())).unwrap(); editor.handle(Input::Char('z')); let picker = editor.diff().unwrap().files.as_ref().unwrap(); assert!(picker.entries().nth(picker.selected).unwrap().is_dir); editor.handle(Input::Enter); let picker = editor.diff().unwrap().files.as_ref().unwrap(); assert!(!picker.entries().nth(picker.selected).unwrap().expanded); assert_eq!(editor.diff().unwrap().file, 0); editor.handle(Input::Char('l')); editor.handle(Input::Char('l')); editor .bind("selection", "ctrl+n", Some("core.down".into())) .unwrap(); editor.handle(Input::Ctrl('n')); assert_eq!(editor.diff().unwrap().file, 0); editor.handle(Input::Char('i')); text(&mut editor, "removed.bin"); editor.handle(Input::Enter); let state = editor.diff().unwrap(); assert_eq!(state.file, 2); assert_eq!(state.focus, Focus::Before); assert!(!state.files.as_ref().unwrap().focused); assert!(state.rows.is_empty()); editor.handle(Input::BackTab); editor.handle(Input::Escape); assert!(!editor.diff().unwrap().files.as_ref().unwrap().searching); editor.handle(Input::Ctrl('u')); assert!( editor .diff() .unwrap() .files .as_ref() .unwrap() .query .is_empty() ); editor.handle(Input::Ctrl('t')); assert_eq!( editor.diff().unwrap().files.as_ref().unwrap().view, FileView::List ); editor.handle(Input::Ctrl('t')); assert_eq!( editor.diff().unwrap().files.as_ref().unwrap().view, FileView::Tree ); editor.handle(Input::Ctrl('r')); editor.handle(Input::Char('i')); text(&mut editor, "removed.bin"); editor.handle(Input::Enter); assert_eq!(editor.diff().unwrap().focus, Focus::Before); assert_eq!(editor.context().text, original); assert_eq!(editor.carets(), carets); assert_eq!(editor.text_revision(), revision); editor.handle(Input::Escape); assert!(editor.diff().is_some()); editor.execute("diff.close"); assert!(editor.diff().is_none()); } #[test] fn explorer_actions_preserve_review_and_ctrl_e_uses_normal_bindings() { let mut editor = editor(); open(&mut editor); let id = choose_pair(&mut editor); editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison()))); editor .bind("tree", "ctrl+e", Some("core.down".into())) .unwrap(); editor.handle(Input::Ctrl('e')); assert!(editor.diff().unwrap().files_visible); assert_eq!( editor .diff() .unwrap() .files .as_ref() .unwrap() .entries() .nth(editor.diff().unwrap().files.as_ref().unwrap().selected) .unwrap() .path, Path::new("added.txt") ); editor.handle(Input::Ctrl('w')); assert!(editor.diff().is_some()); assert!(!editor.diff().unwrap().files_visible); text(&mut editor, " e"); assert!(editor.diff().unwrap().files_visible); assert_eq!(editor.diff().unwrap().focus, Focus::Files); assert_eq!( editor.diff().unwrap().files.as_ref().unwrap().presentation, crate::files::FilePresentation::Docked ); editor.handle(Input::Ctrl('w')); text(&mut editor, " o"); assert_eq!( editor.diff().unwrap().files.as_ref().unwrap().presentation, crate::files::FilePresentation::Floating ); editor.handle(Input::Escape); editor.handle(Input::Escape); assert!(editor.diff().is_some()); editor.execute("diff.close"); assert!(editor.diff().is_none()); } #[test] fn explorer_toggle_is_not_a_revision_selector_control() { let mut editor = editor(); open(&mut editor); editor.handle(Input::Ctrl('e')); assert_eq!(editor.diff().unwrap().selected, 0); editor .bind("selection", "ctrl+e", Some("core.down".into())) .unwrap(); for phase in [Phase::Before, Phase::After] { assert_eq!(editor.diff().unwrap().phase, phase); editor.handle(Input::Ctrl('e')); let state = editor.diff().unwrap(); assert!(state.files_visible); assert_eq!(state.selected, 1); assert_eq!(state.phase, phase); editor.handle(Input::Enter); } } #[test] fn aligned_hunks_navigation_shared_top_independent_horizontal_and_file_reset() { let mut editor = editor(); open(&mut editor); let id = choose_pair(&mut editor); assert!(editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison())))); assert_eq!( editor .diff() .unwrap() .rows .iter() .map(|row| (row.before, row.after, row.kind)) .collect::>(), [ (Some(0), Some(0), RowKind::Equal), (Some(1), Some(1), RowKind::Changed), (None, Some(2), RowKind::Added), (Some(2), Some(3), RowKind::Equal), (Some(3), None, RowKind::Removed), (Some(4), Some(4), RowKind::Equal), ] ); editor.handle(Input::Char('\t')); assert_eq!(editor.diff().unwrap().focus, Focus::Before); editor.handle(Input::Down); editor.handle(Input::Right); assert_eq!(editor.cursor().row, 1); assert_eq!(editor.cursor().column, 1); editor.scroll_diff(Focus::Before, true); editor.set_diff_viewport(id, 0, 1, 1, 0); editor.handle(Input::Char('\t')); assert_eq!(editor.diff().unwrap().focus, Focus::After); editor.handle(Input::Down); editor.handle(Input::Right); assert_eq!(editor.cursor().row, 2); assert_eq!(editor.diff().unwrap().top, 1); editor.set_diff_viewport(id, 0, 2, 1, 1); assert_eq!(editor.diff().unwrap().left_after, 1); assert_eq!(editor.diff().unwrap().left_before, 1); editor.handle(Input::End); assert_eq!(editor.diff().unwrap().top, 2); editor.handle(Input::BackTab); assert_eq!(editor.diff().unwrap().focus, Focus::Before); editor.handle(Input::BackTab); editor.handle(Input::Down); assert_eq!(editor.diff().unwrap().file, 0); editor.handle(Input::Enter); let state = editor.diff().unwrap(); assert_eq!(state.file, 1); assert_eq!((state.top, state.left_before, state.left_after), (0, 0, 0)); assert!( state .rows .iter() .all(|row| row.before.is_none() && row.kind == RowKind::Added) ); editor.handle(Input::BackTab); editor.handle(Input::Down); editor.handle(Input::Enter); assert_eq!(editor.diff().unwrap().file, 2); assert!(editor.diff().unwrap().rows.is_empty()); assert!( editor.diff().unwrap().comparison.as_ref().unwrap().files[2] .notice .is_some() ); editor.handle(Input::BackTab); editor.handle(Input::Down); editor.handle(Input::Enter); assert_eq!(editor.diff().unwrap().file, 0); } #[test] fn comparison_identity_comes_from_accepted_requests_not_working_labels() { let mut editor = editor(); open(&mut editor); let first = choose_pair(&mut editor); assert!(editor.apply_diff_result(first, Ok(Outcome::Comparison(comparison())))); assert_eq!(editor.diff().unwrap().comparison_id, Some(first)); editor.execute("diff.close"); open(&mut editor); assert_eq!(editor.diff().unwrap().comparison_id, None); let second = choose_pair(&mut editor); assert!(second > first); assert!(!editor.apply_diff_result(first, Ok(Outcome::Comparison(comparison())))); assert_eq!(editor.diff().unwrap().comparison_id, None); assert!(editor.apply_diff_result(second, Ok(Outcome::Comparison(comparison())))); assert_eq!(editor.diff().unwrap().comparison_id, Some(second)); } #[test] fn closing_diff_rejects_pending_file_details_even_after_reopening() { let mut editor = editor(); open(&mut editor); let comparison_id = choose_pair(&mut editor); let mut catalog = comparison(); let detail = catalog.files.remove(0); catalog.files.clear(); catalog.files.push(ChangedFile { path: detail.path.clone(), old_path: detail.old_path.clone(), status: detail.status, loaded: false, before: vec![], after: vec![], before_eof: false, after_eof: false, hunks: vec![], notice: None, }); editor.apply_diff_result(comparison_id, Ok(Outcome::Comparison(catalog))); let (file_request, _) = editor.take_diff_request().unwrap(); editor.execute("diff.close"); assert!(!editor.apply_diff_result( file_request, Ok(Outcome::File { comparison_id, file_index: 0, file: detail, }) )); assert!(editor.diff().is_none()); open(&mut editor); assert!(editor.diff().unwrap().comparison.is_none()); } #[test] fn explicit_remote_ref_loader_preserves_selector_position_without_choosing_revision() { let mut state = DiffState::new(); let mut initial = catalog(); let loader = initial .entries .iter_mut() .find(|entry| matches!(entry.target, RevisionTarget::FetchRemote(_))) .unwrap(); loader.target = RevisionTarget::ListRemote("origin".into()); loader.label = "Load refs from origin".into(); state.apply_result(1, Ok(Outcome::Catalog(initial))); state.phase = Phase::After; state.query = "origin".into(); state.refilter(); state.selected = 1; state.choose(); assert!( matches!(state.take_request(), Some(Operation::ListRemote(remote)) if remote == "origin") ); let mut loaded = catalog(); let loader = loaded .entries .iter_mut() .find(|entry| matches!(entry.target, RevisionTarget::FetchRemote(_))) .unwrap(); loader.target = RevisionTarget::ListRemote("origin".into()); loader.label = "Load refs from origin".into(); loaded.entries.push(revision( RevisionTarget::Remote { remote: "origin".into(), reference: "refs/heads/new".into(), }, "new@origin", "", Category::Branches, )); state.apply_result(2, Ok(Outcome::Catalog(loaded))); assert_eq!(state.phase, Phase::After); assert_eq!(state.query, "origin"); assert_eq!(state.category, 0); assert_eq!(state.selected, 1); assert!(state.after.is_none()); assert!(!state.loading); state.category = 1; state.refilter(); assert!( state .entries() .any(|entry| matches!(entry.target, RevisionTarget::Remote { .. })) ); } #[test] fn discovery_and_fetch_errors_are_retryable_and_stale_responses_cannot_reopen() { let mut editor = editor(); editor.execute("diff"); let (old, _) = editor.take_diff_request().unwrap(); editor.handle(Input::Escape); assert!(!editor.apply_diff_result(old, Ok(Outcome::Catalog(catalog())))); editor.execute("diff"); let (id, _) = editor.take_diff_request().unwrap(); assert!(id > old); assert!(!editor.apply_diff_result(old, Err("stale".into()))); assert!(editor.diff().unwrap().loading); assert!(editor.apply_diff_result(id, Err("discovery failed".into()))); assert!(editor.diff().unwrap().error.is_some()); editor.handle(Input::Enter); let (retry, operation) = editor.take_diff_request().unwrap(); assert!(matches!(operation, Operation::Discover)); assert!(retry > id); assert!(editor.apply_diff_result(retry, Ok(Outcome::Catalog(catalog())))); text(&mut editor, "origin"); editor.handle(Input::Down); editor.handle(Input::Enter); let (fetch, _) = editor.take_diff_request().unwrap(); assert!(editor.apply_diff_result(fetch, Err("fetch failed".into()))); assert_eq!(editor.diff().unwrap().entries().len(), 2); assert_eq!(editor.diff().unwrap().query, "origin"); editor.handle(Input::Ctrl('u')); editor.handle(Input::Home); editor.handle(Input::Enter); assert_eq!(editor.diff().unwrap().phase, Phase::After); } #[test] fn immutable_diff_and_selectors_preserve_dirty_buffers_carets_and_undo() { let mut editor = editor(); editor .apply(vec![Effect::Replace("original\\nsecond".into())]) .unwrap(); editor.handle(Input::Char('i')); text(&mut editor, "unsaved"); editor.handle(Input::Escape); editor.handle(Input::Right); let original = editor.context().text; let carets = editor.carets(); let revision = editor.text_revision(); let buffers = editor .buffers() .map(|buffer| (buffer.id, buffer.dirty)) .collect::>(); editor .bind("normal", "x", Some("core.diff".into())) .unwrap(); assert!(editor.handle(Input::Char('x')).is_none()); let (id, _) = editor.take_diff_request().unwrap(); assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(catalog())))); for input in [ Input::Char('i'), Input::Char('d'), Input::Delete, Input::Paste("bad\\n:q!".into()), ] { assert!(editor.handle(input).is_none()); } assert!(editor.execute("bdelete!").is_none()); assert!(editor.apply(vec![Effect::Replace("bad".into())]).is_err()); assert_eq!(editor.context().text, original); assert_eq!(editor.carets(), carets); assert_eq!(editor.text_revision(), revision); assert_eq!( editor .buffers() .map(|buffer| (buffer.id, buffer.dirty)) .collect::>(), buffers ); editor.handle(Input::Escape); assert!(editor.diff().is_none()); assert_eq!(editor.mode(), Mode::Normal); open(&mut editor); let id = choose_pair(&mut editor); editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison()))); for input in [ Input::Char('i'), Input::Char('u'), Input::Char('d'), Input::Paste("bad".into()), Input::Delete, Input::Backspace, Input::Enter, ] { editor.handle(input); } editor.handle(Input::Escape); editor.handle(Input::Escape); assert!(editor.diff().is_some()); editor.execute("diff.close"); assert_eq!(editor.context().text, original); assert_eq!(editor.carets(), carets); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, "original\\nsecond"); } #[test] fn jujutsu_categories_empty_comparison_and_command_mode_entry_are_complete() { let mut editor = editor(); editor.handle(Input::Char(':')); text(&mut editor, "diff"); editor.handle(Input::Enter); assert_eq!(editor.mode(), Mode::Normal); let (id, _) = editor.take_diff_request().unwrap(); let mut catalog = catalog(); catalog.kind = RepoKind::Jujutsu; catalog.entries[0].category = Category::Revisions; catalog.entries[1].category = Category::Revisions; catalog.entries[4].category = Category::Bookmarks; assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(catalog)))); assert_eq!(editor.diff().unwrap().entries().len(), 2); editor.handle(Input::Char('\t')); assert_eq!( editor.diff().unwrap().entries().nth(1).unwrap().category, Category::Bookmarks ); editor.handle(Input::Home); editor.handle(Input::Enter); editor.handle(Input::Enter); let (id, _) = editor.take_diff_request().unwrap(); assert!(editor.apply_diff_result( id, Ok(Outcome::Comparison(Comparison { revisions: ["working:snapshot".into(), "working:snapshot".into()], before: "Working".into(), after: "Working".into(), files: vec![], root: PathBuf::new(), editable_paths: BTreeSet::new(), })) )); assert_eq!(editor.diff().unwrap().phase, Phase::View); assert!(editor.diff().unwrap().rows.is_empty()); for input in [Input::Down, Input::End, Input::Up, Input::Right, Input::End] { editor.handle(input); } assert_eq!(editor.diff().unwrap().top, 0); assert_eq!(editor.diff().unwrap().file, 0); editor.handle(Input::Escape); assert!(editor.diff().is_some()); editor.execute("diff.close"); assert!(editor.diff().is_none()); } fn live_state(before: Vec, after: Vec, eof: bool) -> DiffState { let mut state = DiffState::new(); state.phase = Phase::View; state.focus = Focus::After; state.after = Some(revision( RevisionTarget::Working, "Working", "", Category::Commits, )); state.comparison = Some(Comparison { revisions: ["base".into(), "working:snapshot".into()], root: PathBuf::new(), before: "base".into(), after: "Working".into(), editable_paths: BTreeSet::from([PathBuf::from("file")]), files: vec![ChangedFile { path: "file".into(), old_path: None, status: FileStatus::Modified, hunks: line_hunks(&before, eof, &after, eof), before, after, before_eof: eof, after_eof: eof, notice: None, loaded: true, }], }); state.files = Some(FilePicker::from_paths( PathBuf::new(), [(0, PathBuf::from("file"))], )); state.align_file(); state } #[test] fn column_view_preserves_split_gaps_unified_sides_and_folded_coordinates() { let mut state = live_state( ["head", "tail"].map(str::to_owned).to_vec(), ["head", "added", "tail"].map(str::to_owned).to_vec(), false, ); let before = state.column_view(Focus::Before).unwrap(); assert!(matches!(before.row(1), Some(ColumnRow::Gap))); assert_eq!(before.native_row(1), None); assert_eq!(before.display_row(1), Some(2)); state.set_view_mode(ViewMode::Unified); let before = state.column_view(Focus::Before).unwrap(); let ColumnRow::Text(line) = before.row(1).unwrap() else { panic!("unified insertion must paint the after source"); }; assert_eq!( (line.text, line.side, line.source_row), (Some("added"), Focus::After, 1) ); assert_eq!(line.numbers, [None, Some(1)]); assert!(line.changed); assert_eq!(before.native_row(1), None); assert_eq!(before.review_row(Focus::Before, 1), None); assert_eq!(before.review_row(Focus::After, 1), Some(1)); let ColumnRow::Text(line) = before.row(2).unwrap() else { panic!("equal context must contain text"); }; assert_eq!(line.numbers, [Some(1), Some(2)]); assert_eq!(before.native_row(2), Some(1)); assert_eq!(before.review_row(Focus::Before, 2), Some(1)); assert_eq!(before.review_row(Focus::After, 2), Some(2)); assert_eq!(before.display_row(1), Some(2)); for invalid in [before.info.display_len, usize::MAX] { assert!(before.row(invalid).is_none()); assert_eq!(before.native_row(invalid), None); assert_eq!(before.review_row(Focus::After, invalid), None); } assert_eq!(before.display_row(usize::MAX), None); let lines: Vec<_> = (0..30).map(|row| format!("line {row}")).collect(); let mut changed = lines.clone(); changed[15] = "changed".into(); let mut state = live_state(lines, changed, false); state.set_context_options(0, 1); for mode in [ViewMode::Split, ViewMode::Unified] { state.set_view_mode(mode); let view = state.column_view(Focus::After).unwrap(); assert!(matches!(view.row(0), Some(ColumnRow::Fold { hidden: 15 }))); assert_eq!(view.native_row(0), None); assert_eq!(view.review_row(Focus::After, 0), None); assert_eq!(view.display_row(0), None); let display = view.display_row(15).unwrap(); let ColumnRow::Text(line) = view.row(display).unwrap() else { panic!("visible changed source must contain text"); }; assert_eq!((line.text, line.source_row), (Some("changed"), 15)); assert_eq!(view.native_row(display), Some(15)); } } #[test] fn column_view_distinguishes_native_terminal_rows_from_missing_ordinary_text() { for text in ["", "same\n"] { let lines: Vec<_> = text.split('\n').map(str::to_owned).collect(); let mut state = DiffState::new(); state.set_resolver_text(&lines, &lines); for focus in [Focus::Before, Focus::After] { let view = state.column_view(focus).unwrap(); let native = lines.len() - 1; let display = view.display_row(native).unwrap(); let ColumnRow::Text(line) = view.row(display).unwrap() else { panic!("native terminal row must contain text"); }; assert_eq!((line.text, line.source_row), (Some(""), native)); assert_eq!(line.newline, text.ends_with('\n')); assert_eq!(view.native_row(display), Some(native)); assert_eq!(view.info.source_extents, [lines.len(); 2]); assert_eq!(view.info.stored_lengths, [lines.len() - 1; 2]); } } let mut state = live_state(vec!["old".into()], vec!["new".into()], false); // A mapped but unavailable ordinary source is not a synthetic terminal row. state.comparison.as_mut().unwrap().files[0].after.clear(); let view = state.column_view(Focus::After).unwrap(); let ColumnRow::Text(line) = view.row(0).unwrap() else { panic!("missing source text must retain its mapped row"); }; assert_eq!(line.text, None); assert_eq!(line.source_row, 0); } #[test] fn column_view_suppresses_whole_file_emphasis_without_losing_change_markers() { let mut state = live_state(vec!["abc".into()], vec!["aXYbc".into()], false); for (focus, expected) in [(Focus::Before, 1..1), (Focus::After, 1..3)] { let view = state.column_view(focus).unwrap(); let ColumnRow::Text(line) = view.row(0).unwrap() else { panic!("changed row must contain text"); }; assert!(line.changed && line.emphasize); assert_eq!(line.changes().cloned().collect::>(), [expected]); } for status in [FileStatus::Added, FileStatus::Removed] { state.comparison.as_mut().unwrap().files[0].status = status; for focus in [Focus::Before, Focus::After] { let view = state.column_view(focus).unwrap(); let ColumnRow::Text(line) = view.row(0).unwrap() else { panic!("whole-file status must retain source text"); }; assert!(line.changed); assert!(!line.emphasize); assert_eq!(line.changes().count(), 0); } } } #[test] fn working_refresh_preserves_noops_errors_and_view_state_but_invalidates_old_identity() { let mut state = live_state(vec!["old".into()], vec!["new".into()], false); state.loading = false; state.pending = None; state.request_kind = None; state.comparison_id = Some(7); state.word_wrap = true; state.view_mode = ViewMode::Unified; state.comparison_mode = ComparisonMode::Snapshots; state.split_axis = crate::panes::SplitAxis::Horizontal; state.files_visible = false; state.reviewed_files.insert(0); state.error = Some("previous notice".into()); let files = state.files.as_mut().unwrap(); files.set_view(FileView::Tree); files.start_search(); files.query = "file".into(); files.refilter(); state.queue_checkout_refresh(true); assert!(matches!( state.take_request(), Some(Operation::RefreshCheckout { comparison_id: 7, .. }) )); assert!(state.apply_result(8, Ok(Outcome::CheckoutRefresh(None)))); assert!(state.error.is_none()); assert_eq!(state.comparison_id, Some(7)); assert_eq!(state.reviewed_files, BTreeSet::from([0])); state.queue_checkout_refresh(true); state.take_request(); assert!(state.apply_result(9, Err("refresh failed".into()))); assert_eq!(state.comparison_id, Some(7)); assert_eq!(state.comparison.as_ref().unwrap().files[0].after, ["new"]); assert_eq!(state.reviewed_files, BTreeSet::from([0])); assert_eq!(state.error.as_deref(), Some("refresh failed")); state.queue_checkout_refresh(true); state.take_request(); let mut replacement = live_state(vec!["old".into()], vec!["ahead".into()], false) .comparison .take() .unwrap(); replacement.files.insert(0, comparison().files.remove(0)); assert!(state.apply_result(10, Ok(Outcome::CheckoutRefresh(Some(replacement))))); assert_eq!(state.comparison_id, Some(10)); assert_eq!(state.file, 1); assert_eq!(state.focus, Focus::After); assert!(state.word_wrap && !state.files_visible); assert_eq!(state.split_axis, crate::panes::SplitAxis::Horizontal); assert_eq!(state.view_mode, ViewMode::Unified); assert_eq!(state.comparison_mode, ComparisonMode::Snapshots); assert!(state.reviewed_files.is_empty()); let files = state.files.as_ref().unwrap(); assert_eq!(files.query, "file"); assert_eq!(files.view, FileView::Tree); assert!(files.searching); assert_eq!( files.entries().nth(files.selected).unwrap().path, Path::new("file") ); assert!(!state.apply_result(7, Ok(Outcome::CheckoutRefresh(None)))); assert_eq!(state.comparison_id, Some(10)); assert_eq!(state.file, 1); state.after.as_mut().unwrap().target = RevisionTarget::Commit("historical".into()); state.queue_checkout_refresh(false); assert!(state.request_queued()); assert!(matches!( state.take_request(), Some(Operation::RefreshCheckout { follow_working: false, .. }) )); assert!(!state.apply_result(11, Ok(Outcome::CheckoutRefresh(None)))); state.reviewed_files.insert(1); state.queue_checkout_refresh(false); state.take_request(); let mut demoted = live_state(vec!["old".into()], vec!["ahead".into()], false) .comparison .take() .unwrap(); demoted.files.insert(0, comparison().files.remove(0)); demoted.editable_paths.clear(); assert!(state.apply_result(12, Ok(Outcome::CheckoutRefresh(Some(demoted))))); assert_eq!(state.reviewed_files, BTreeSet::from([1])); assert!(!state.live_after_available()); state .comparison .as_mut() .unwrap() .editable_paths .insert("file".into()); state.refresh_after(&["dirty".into()], 1); assert_eq!(state.comparison.as_ref().unwrap().files[1].after, ["dirty"]); state.queue_checkout_refresh(false); state.take_request(); assert!(state.apply_result(13, Err("checkout unavailable".into()))); assert!(!state.live_after_available()); assert_eq!(state.comparison_id, Some(12)); assert_eq!(state.comparison.as_ref().unwrap().files[1].after, ["ahead"]); state.after.as_mut().unwrap().target = RevisionTarget::Working; state.queue_checkout_refresh(false); assert!(!state.request_queued()); state.load_file(0); state.queue_checkout_refresh(true); assert!(matches!( state.pending, Some(Operation::LoadFile { file_index: 0, .. }) )); } #[test] fn reviewed_files_use_highlighted_catalog_indices_not_sorted_or_filtered_rows() { let mut state = DiffState::new(); let mut changes = comparison(); changes.files[0].path = "nested/z.txt".into(); changes.files[1].path = "nested/a.txt".into(); state.request_kind = Some(RequestKind::Comparison); state.apply_result(1, Ok(Outcome::Comparison(changes))); for view in [FileView::List, FileView::Tree] { let files = state.files.as_mut().unwrap(); files.set_view(view); files.clear_filter(); files.start_search(); for ch in "nested/a".chars() { files.handle_filter_input(&Input::Char(ch)); } files.reveal_path(Path::new("nested/a.txt")); assert_eq!(state.file, 0); assert_eq!(state.toggle_reviewed(), Ok(true)); assert_eq!(state.reviewed_files, BTreeSet::from([1])); assert_eq!(state.toggle_reviewed(), Ok(false)); assert!(state.reviewed_files.is_empty()); } let files = state.files.as_mut().unwrap(); files.clear_filter(); files.finish_search(); let selected = files.entries().position(|entry| entry.is_dir).unwrap(); files.selected = selected; assert_eq!( state.toggle_reviewed(), Err("Select a changed file, not a directory") ); assert!(state.reviewed_files.is_empty()); let files = state.files.as_mut().unwrap(); files.start_search(); for ch in "missing".chars() { files.handle_filter_input(&Input::Char(ch)); } assert_eq!(state.toggle_reviewed(), Err("No changed file is selected")); state.files = Some(FilePicker::from_paths( PathBuf::new(), [(0, PathBuf::from("ordinary.txt"))], )); assert_eq!(state.toggle_reviewed(), Err("No changed file is selected")); state.focus = Focus::Before; assert_eq!(state.toggle_reviewed(), Ok(true)); assert_eq!(state.reviewed_files, BTreeSet::from([0])); assert!(state.select_file_index(2)); state.focus = Focus::Before; assert_eq!(state.toggle_reviewed(), Ok(true)); assert_eq!(state.reviewed_files, BTreeSet::from([0, 2])); } #[test] fn reviewed_files_survive_lazy_details_and_views_but_reset_on_comparison() { let mut state = DiffState::new(); assert_eq!(state.toggle_reviewed(), Err("No comparison is open")); let mut changes = comparison(); changes.files[0].loaded = false; changes.files[0].before.clear(); changes.files[0].after.clear(); state.request_kind = Some(RequestKind::Comparison); state.apply_result(1, Ok(Outcome::Comparison(changes))); assert_eq!(state.toggle_reviewed(), Ok(true)); assert_eq!(state.reviewed_files, BTreeSet::from([0])); let file = comparison().files.remove(0); state.apply_result( 2, Ok(Outcome::File { comparison_id: 1, file_index: 0, file, }), ); assert!(state.selected_file_loaded()); state.set_view_mode(ViewMode::Unified); state.set_view_mode(ViewMode::Split); state.split_axis = crate::panes::SplitAxis::Horizontal; assert!(state.select_file_index(1)); assert_eq!(state.reviewed_files, BTreeSet::from([0])); state.before = Some(revision( RevisionTarget::Commit("base".into()), "base", "", Category::Commits, )); state.after = Some(revision( RevisionTarget::Commit("head".into()), "head", "", Category::Commits, )); state.toggle_comparison_mode().unwrap(); assert!(state.reviewed_files.is_empty()); assert_eq!(state.toggle_reviewed(), Err("No comparison is open")); state.apply_result(3, Ok(Outcome::Comparison(comparison()))); assert!(state.reviewed_files.is_empty()); assert_eq!(state.toggle_reviewed(), Ok(true)); state.request_kind = Some(RequestKind::Comparison); state.apply_result(4, Ok(Outcome::Comparison(comparison()))); assert!(state.reviewed_files.is_empty()); } #[test] fn restored_review_marks_use_revision_pairs_and_rename_paths_not_catalog_order() { let mut state = DiffState::new(); state.request_kind = Some(RequestKind::Comparison); state.apply_result(1, Ok(Outcome::Comparison(comparison()))); assert_eq!(state.toggle_reviewed(), Ok(true)); state.comparison.as_mut().unwrap().files.swap(0, 1); state.restore_reviewed_files(); assert_eq!(state.reviewed_files, BTreeSet::from([1])); state.comparison.as_mut().unwrap().files[1].old_path = Some("different.txt".into()); state.restore_reviewed_files(); assert!(state.reviewed_files.is_empty()); state.comparison.as_mut().unwrap().files[1].old_path = Some("old.txt".into()); for side in [0, 1] { let original = state.comparison.as_ref().unwrap().revisions[side].clone(); state.comparison.as_mut().unwrap().revisions[side].push_str("-different"); state.restore_reviewed_files(); assert!(state.reviewed_files.is_empty()); state.comparison.as_mut().unwrap().revisions[side] = original; } state.comparison.as_mut().unwrap().files[1].loaded = false; state.restore_reviewed_files(); assert_eq!(state.reviewed_files, BTreeSet::from([1])); } #[test] fn restored_live_review_marks_require_exact_text_and_final_newline() { let mut state = live_state(vec!["base".into()], vec!["changed".into()], false); state.refresh_after(&["unsaved".into(), String::new()], 1); assert_eq!(state.toggle_reviewed(), Ok(true)); let document = state.local_review.document.clone(); let mut reopened = live_state(vec!["base".into()], vec!["changed".into()], false); reopened.local_review.document = document.clone(); reopened.restore_reviewed_files(); assert!(reopened.reviewed_files.is_empty()); reopened.refresh_after(&["unsaved".into(), String::new()], 1); assert_eq!(reopened.reviewed_files, BTreeSet::from([0])); reopened.refresh_after(&["unsaved".into()], 2); assert!(reopened.reviewed_files.is_empty()); assert!(reopened.local_review.document.reviewed_files.is_empty()); reopened.local_review.document = document; reopened.restore_reviewed_files(); assert!(reopened.reviewed_files.is_empty()); } #[test] fn working_review_marks_clear_only_for_changed_bytes_or_eof() { for (after, eof, lines) in [ (vec!["changed".into()], false, vec!["changed".into()]), ( vec!["changed".into()], true, vec!["changed".into(), String::new()], ), (Vec::new(), false, vec![String::new()]), ] { let mut state = live_state(vec!["base".into()], after, eof); assert_eq!(state.toggle_reviewed(), Ok(true)); state.refresh_after(&lines, 1); assert_eq!(state.reviewed_files, BTreeSet::from([0])); state.refresh_after(&lines, 2); assert_eq!(state.reviewed_files, BTreeSet::from([0])); state.refresh_after(&["edited".into()], 3); assert!(state.reviewed_files.is_empty()); assert_eq!(state.toggle_reviewed(), Ok(true)); state.refresh_after(&["edited".into(), String::new()], 4); assert!(state.reviewed_files.is_empty()); assert_eq!(state.toggle_reviewed(), Ok(true)); state.refresh_after(&["edited".into()], 5); assert!(state.reviewed_files.is_empty()); } let mut state = live_state(vec!["base".into()], vec!["changed".into()], false); state.comparison.as_mut().unwrap().files[0].loaded = false; assert_eq!(state.toggle_reviewed(), Ok(true)); state.refresh_after(&["edited".into()], 1); assert_eq!(state.reviewed_files, BTreeSet::from([0])); } #[test] fn unified_groups_changed_runs_and_roundtrips_side_specific_source_rows() { let mut state = live_state( ["head", "old one", "old two", "tail"] .map(str::to_owned) .to_vec(), ["head", "new one", "new two", "extra", "tail"] .map(str::to_owned) .to_vec(), false, ); assert_eq!(state.view_mode, ViewMode::Split); assert_eq!(state.split_axis, crate::panes::SplitAxis::Vertical); state.top = state.after_display_row(2).unwrap(); let excerpts = state.excerpt_ranges.clone(); state.set_view_mode(ViewMode::Unified); let rows: Vec<_> = (0..state.display_rows.len()) .map(|display| { ( state.display_focus(display), state.display_source_row(Focus::Before, display), state.display_source_row(Focus::After, display), ) }) .collect(); assert_eq!( rows, [ (None, Some(0), Some(0)), (Some(Focus::Before), Some(1), None), (Some(Focus::Before), Some(2), None), (Some(Focus::After), None, Some(1)), (Some(Focus::After), None, Some(2)), (Some(Focus::After), None, Some(3)), (None, Some(3), Some(4)), ] ); assert_eq!(state.top, state.after_display_row(2).unwrap()); assert_eq!( state.corresponding_row(Focus::Before, Focus::After, 2), Some(2) ); assert!(state.after_editable()); for focus in [Focus::Before, Focus::After] { for source in state.source_rows_in(focus, 0, usize::MAX) { let display = state.source_display_row(focus, source).unwrap(); assert_eq!(state.display_source_row(focus, display), Some(source)); } } state.set_view_mode(ViewMode::Split); assert_eq!(state.excerpt_ranges, excerpts); assert_eq!(state.focus, Focus::After); assert_eq!(state.top, state.after_display_row(2).unwrap()); assert_eq!(state.display_rows.len(), 5); } #[test] fn unified_live_reflow_preserves_removed_top_and_expanded_source_excerpts() { let base: Vec<_> = (0..40).map(|row| format!("line {row}")).collect(); let mut lines = base.clone(); lines[20] = "new line".into(); let mut state = live_state(base, lines.clone(), false); state.set_context_options(1, 3); state.refresh_after(&lines, 1); state.set_view_mode(ViewMode::Unified); state.top = state.source_display_row(Focus::Before, 20).unwrap(); state.expand_source_excerpt(Focus::After, false, 20); assert_eq!(state.display_source_row(Focus::Before, state.top), Some(20)); assert!(state.source_display_row(Focus::After, 16).is_some()); lines.insert(0, "inserted".into()); state.refresh_after(&lines, 2); assert_eq!(state.display_focus(state.top), Some(Focus::Before)); assert_eq!(state.display_source_row(Focus::Before, state.top), Some(20)); assert!(state.source_display_row(Focus::After, 17).is_some()); state.reveal_source_row(Focus::After, 35); let visible: Vec<_> = state.source_rows_in(Focus::After, 0, usize::MAX).collect(); state.set_view_mode(ViewMode::Split); state.set_view_mode(ViewMode::Unified); assert_eq!( state .source_rows_in(Focus::After, 0, usize::MAX) .collect::>(), visible ); assert_eq!(state.display_source_row(Focus::After, state.top), Some(35)); } #[test] fn character_ranges_follow_insertions_deletions_unicode_and_live_edits() { for (before, after, expected) in [ ("ab", "a界b", vec![[1..1, 1..2]]), ("a界b", "ab", vec![[1..2, 1..1]]), ("xe\u{301}👩‍🚀z", "xe\u{300}👩‍🔬z", vec![[1..3, 1..3]]), ] { let state = live_state(vec![before.into()], vec![after.into()], false); assert_eq!(state.rows[0].changes, expected); } let mut state = live_state(vec!["abc".into()], vec!["aXbc".into()], false); state.refresh_after(&["aXYbc".into()], 1); assert_eq!(state.rows[0].changes, vec![[1..1, 1..3]]); state.refresh_after(&["abYZc".into()], 2); assert_eq!(state.rows[0].changes, vec![[2..2, 2..4]]); state.refresh_after(&["abc".into()], 3); assert_eq!(state.rows[0].kind, RowKind::Equal); assert!(state.rows[0].changes.is_empty()); } #[test] fn character_highlights_merge_incidental_matches_but_preserve_meaningful_gaps() { let mut cases = vec![ ("a..c".into(), "b..d".into(), vec![[0..4, 0..4]]), ("base".into(), "edited".into(), vec![[0..4, 0..6]]), ( "a...c".into(), "b...d".into(), vec![[0..1, 0..1], [4..5, 4..5]], ), ( "a界e\u{301}c".into(), "b界e\u{301}d".into(), vec![[0..4, 0..4]], ), ]; for (size, gap, merge) in [ (60, " = ".to_owned(), false), (70, " = ".to_owned(), true), (70, "x".repeat(20), true), (70, "unchanged_expression".to_owned(), true), (70, "unchanged_expression_".to_owned(), false), (70, " ".repeat(500), true), (70, " ".repeat(501), false), ] { let end = 2 * size + gap.len(); let second = size + gap.len(); cases.push(( format!("{}{}{}", "a".repeat(size), gap, "c".repeat(size)), format!("{}{}{}", "b".repeat(size), gap, "d".repeat(size)), if merge { vec![[0..end, 0..end]] } else { vec![[0..size, 0..size], [second..end, second..end]] }, )); } cases.push(( format!("aaa = {} = {}", "c".repeat(70), "e".repeat(70)), format!("bbb = {} = {}", "d".repeat(70), "f".repeat(70)), vec![[0..149, 0..149]], )); for (before, after, expected) in cases { let mut state = live_state(vec![before], vec![after.clone()], false); assert_eq!(state.rows[0].changes, expected, "{after:?}"); state.refresh_after(&[format!("{after}!")], 1); state.refresh_after(std::slice::from_ref(&after), 2); assert_eq!(state.rows[0].changes, expected, "live edit: {after:?}"); } } #[test] fn reverting_loaded_file_keeps_unloaded_changes_selectable() { let mut state = live_state(vec!["base".into()], vec!["changed".into()], false); state.comparison_id = Some(17); state.pending = None; state.comparison.as_mut().unwrap().files.push(ChangedFile { path: "unloaded.txt".into(), old_path: None, status: FileStatus::Modified, loaded: false, before: vec![], after: vec![], before_eof: false, after_eof: false, hunks: vec![], notice: None, }); state.refresh_after(&["base".into()], 1); assert_eq!( state .files .as_ref() .unwrap() .entries() .map(|entry| entry.path) .collect::>(), [Path::new("unloaded.txt")], ); assert!(state.select_file(Path::new("unloaded.txt"))); assert!(matches!( state.take_request(), Some(Operation::LoadFile { comparison_id: 17, file_index: 1 }) )); } #[test] fn live_width_cache_scans_only_changed_lines_and_shrinks_after_deletion() { let base = vec!["original".into(), "medium".into(), "short".into()]; let mut lines = vec!["界\tz".into(), "medium".into(), "short".into()]; let mut state = live_state(base, lines.clone(), false); state.refresh_after(&lines, 1); assert_eq!(state.after_widths, [5, 6, 5]); lines[0] = "界界\t🙂".into(); WIDTH_SCANS.set(0); state.refresh_after(&lines, 2); assert_eq!(WIDTH_SCANS.get(), 1); assert_eq!(state.max_after, 10); state.left_after = 9; lines[0] = "界".into(); WIDTH_SCANS.set(0); state.refresh_after(&lines, 3); assert_eq!(WIDTH_SCANS.get(), 1); assert_eq!(state.max_after, 6); assert_eq!(state.left_after, 6); lines.remove(1); WIDTH_SCANS.set(0); state.refresh_after(&lines, 4); assert_eq!(WIDTH_SCANS.get(), 0); assert_eq!(state.after_widths, [2, 5]); assert_eq!(state.max_after, 5); lines.insert(1, "巨大\t🙂".into()); WIDTH_SCANS.set(0); state.refresh_after(&lines, 5); assert_eq!(WIDTH_SCANS.get(), 1); assert_eq!(state.after_widths, [2, 10, 5]); lines = vec!["".into()]; state.refresh_after(&lines, 6); assert_eq!(state.after_widths, [0]); assert_eq!(state.max_after, 0); assert_eq!(state.left_after, 0); } #[test] fn stable_line_edits_keep_fresh_hunks_expansion_and_scroll_anchor_until_revert() { let base = (0..100) .map(|row| format!("line {row}")) .collect::>(); let mut lines = base.clone(); lines[30] = "first change".into(); let mut state = live_state(base.clone(), lines.clone(), true); state.set_context_options(2, 5); lines.push(String::new()); state.refresh_after(&lines, 1); state.top = state.after_display_row(30).unwrap(); state.handle(Input::Char('[')); let display = state.display_rows.clone(); let excerpts = state.excerpt_ranges.clone(); let top = state.top; lines[30].push('!'); let file = &state.comparison.as_ref().unwrap().files[0]; assert!(stable_after_edit( file, &lines, changed_line_range(&file.after, &lines) )); WIDTH_SCANS.set(0); state.refresh_after(&lines, 2); assert_eq!(WIDTH_SCANS.get(), 1); assert_eq!(state.display_rows, display); assert_eq!(state.excerpt_ranges, excerpts); assert_eq!(state.top, top); let file = &state.comparison.as_ref().unwrap().files[0]; let hunk_ranges = |hunks: &[Hunk]| { hunks .iter() .map(|hunk| { ( hunk.before_start, hunk.before_len, hunk.after_start, hunk.after_len, ) }) .collect::>() }; assert_eq!( hunk_ranges(&file.hunks), hunk_ranges(&line_hunks(&base, true, live_content(&lines, true), true)) ); lines[30].clone_from(&base[30]); assert!(!stable_after_edit( file, &lines, changed_line_range(&file.after, &lines) )); state.refresh_after(&lines, 3); assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty()); assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal)); assert!(state.after_display_row(23).is_some()); assert_eq!(state.top, state.after_display_row(30).unwrap()); assert_eq!(state.files.as_ref().unwrap().entries().len(), 0); lines[50] = "changed again".into(); state.refresh_after(&lines, 4); assert_eq!(state.files.as_ref().unwrap().entries().len(), 1); assert!(state.after_display_row(23).is_some()); assert!(state.after_display_row(50).is_some()); } #[test] fn live_reflow_keeps_separate_hunks_and_expanded_context_after_revert() { let base = (0..100) .map(|row| format!("line {row}")) .collect::>(); let mut changed = base.clone(); changed[30] = "first change".into(); changed[70] = "second change".into(); let mut state = live_state(base.clone(), changed.clone(), true); state.set_context_options(2, 5); changed.push(String::new()); state.refresh_after(&changed, 1); assert_eq!(state.comparison.as_ref().unwrap().files[0].hunks.len(), 2); assert!( state .rows .last() .is_some_and(|row| row.after == Some(100) && row.kind == RowKind::Equal) ); state.top = state.after_display_row(30).unwrap(); state.handle(Input::Char('[')); assert!(state.after_display_row(23).is_some()); changed.insert(10, "inserted outside excerpts".into()); state.refresh_after(&changed, 2); assert!(state.after_display_row(24).is_some()); assert_eq!(state.comparison.as_ref().unwrap().files[0].hunks.len(), 3); assert!( state .display_rows .iter() .any(|row| matches!(row, DisplayRow::Fold { start, end } if *start < 60 && *end > 40)) ); let mut reverted = base; reverted.push(String::new()); state.refresh_after(&reverted, 3); assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty()); assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal)); assert!(state.after_display_row(23).is_some()); assert!(state.after_display_row(90).is_none()); assert_eq!(state.files.as_ref().unwrap().entries().len(), 0); assert_eq!(state.file, 0); reverted[50] = "changed again".into(); state.refresh_after(&reverted, 4); assert_eq!(state.files.as_ref().unwrap().entries().len(), 1); assert!(state.after_display_row(50).is_some()); } #[test] fn live_empty_and_eof_rows_match_buffer_without_false_eof_highlights() { let mut state = live_state(vec!["same".into()], vec!["changed".into()], true); state.refresh_after(&["same".into(), "".into()], 1); assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty()); assert_eq!(state.rows.len(), 2); assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal)); assert!(state.after_display_row(0).is_some()); assert!(state.after_display_row(1).is_none()); state.refresh_after(&["same".into()], 2); assert_eq!(state.rows[0].kind, RowKind::Changed); assert!( state.comparison.as_ref().unwrap().files[0] .notice .as_ref() .unwrap() .contains("After missing") ); state.refresh_after(&["".into()], 3); assert!(state.after_display_row(0).is_some()); assert_eq!(state.comparison.as_ref().unwrap().files[0].after, [""]); let mut empty = live_state(vec![], vec!["text".into()], false); empty.refresh_after(&["".into()], 1); assert!(empty.comparison.as_ref().unwrap().files[0].hunks.is_empty()); assert_eq!(empty.rows.len(), 1); assert_eq!(empty.rows[0].kind, RowKind::Equal); assert_eq!(empty.after_display_row(0), Some(0)); empty.after.as_mut().unwrap().target = RevisionTarget::Commit("historic".into()); empty.comparison.as_mut().unwrap().editable_paths.clear(); empty.refresh_after(&["must not apply".into()], 2); assert_eq!(empty.comparison.as_ref().unwrap().files[0].after, [""]); empty.handle(Input::Escape); assert_eq!(empty.phase, Phase::View); } #[test] fn lazy_selection_waits_for_details_rejects_stale_files_and_reuses_loaded_cache() { let mut state = DiffState::new(); state.pending = None; state.request_kind = Some(RequestKind::Comparison); let mut catalog = comparison(); catalog.files.truncate(2); let details: Vec<_> = catalog.files.drain(..).collect(); for detail in &details { catalog.files.push(ChangedFile { path: detail.path.clone(), old_path: detail.old_path.clone(), status: detail.status, loaded: false, before: vec![], after: vec![], before_eof: false, after_eof: false, hunks: vec![], notice: None, }); } state.apply_result(41, Ok(Outcome::Comparison(catalog))); assert!(!state.selected_file_loaded()); assert!(state.rows.is_empty() && state.display_rows.is_empty()); assert_eq!(state.pane_layout(), PaneLayout::Empty); assert!(!state.after_editable()); assert!(matches!( state.take_request(), Some(Operation::LoadFile { comparison_id: 41, file_index: 0, }) )); assert!(state.select_file(Path::new("added.txt"))); assert!(matches!( state.take_request(), Some(Operation::LoadFile { comparison_id: 41, file_index: 1, }) )); let mut details = details.into_iter(); let first = details.next().unwrap(); let second = details.next().unwrap(); state.apply_result( 42, Ok(Outcome::File { comparison_id: 40, file_index: 1, file: first, }), ); assert!(state.loading && !state.selected_file_loaded()); state.apply_result( 43, Ok(Outcome::File { comparison_id: 41, file_index: 1, file: second, }), ); assert!(state.selected_file_loaded()); assert_eq!(state.pane_layout(), PaneLayout::After); assert!(!state.rows.is_empty()); assert!(state.select_file(Path::new("renamed.txt"))); assert!(state.loading); assert!(state.select_file(Path::new("added.txt"))); assert!(!state.loading && state.selected_file_loaded()); assert!(state.take_request().is_none()); state.apply_result(44, Err("cancelled old load".into())); assert!(state.error.is_none() && state.selected_file_loaded()); } #[test] fn failed_lazy_file_load_can_be_retried_without_reopening_comparison() { let mut state = DiffState::new(); state.pending = None; state.request_kind = Some(RequestKind::Comparison); let mut catalog = comparison(); catalog.files.truncate(1); catalog.files[0].loaded = false; state.apply_result(7, Ok(Outcome::Comparison(catalog))); state.take_request(); state.apply_result(8, Err("cannot read blob".into())); assert_eq!(state.error.as_deref(), Some("cannot read blob")); assert!(!state.selected_file_loaded()); assert!(state.select_file(Path::new("renamed.txt"))); assert!(state.loading && state.error.is_none()); assert!(matches!( state.take_request(), Some(Operation::LoadFile { comparison_id: 7, file_index: 0, }) )); } #[test] fn mode_action_keeps_filter_input_and_rejects_superseded_comparisons() { let mut editor = editor(); open(&mut editor); editor .bind("normal", "m", Some("core.diff.mode.toggle".into())) .unwrap(); text(&mut editor, "m"); assert_eq!(editor.diff().unwrap().query, "m"); assert_eq!( editor.diff().unwrap().comparison_mode, ComparisonMode::BranchChanges ); editor.handle(Input::Ctrl('u')); editor.handle(Input::Down); editor.handle(Input::Enter); editor.handle(Input::Down); editor.handle(Input::Enter); let (old, _) = editor.take_diff_request().unwrap(); editor.execute("diff.mode.toggle"); assert!(!editor.apply_diff_result(old, Err("superseded".into()))); let (current, operation) = editor.take_diff_request().unwrap(); assert!(matches!( operation, Operation::Compare { mode: ComparisonMode::Snapshots, .. } )); assert!(editor.apply_diff_result(current, Err("retryable".into()))); assert_eq!(editor.diff().unwrap().phase, Phase::After); assert_eq!(editor.diff().unwrap().error.as_deref(), Some("retryable")); editor.execute("diff.mode.toggle"); let (_, operation) = editor.take_diff_request().unwrap(); assert!(matches!( operation, Operation::Compare { mode: ComparisonMode::BranchChanges, .. } )); assert!(editor.diff().unwrap().error.is_none()); } #[test] fn review_anchors_match_frozen_context_and_exact_live_eof_without_allocating() { use crate::review::{Anchor, Side}; let mut state = live_state(vec!["old".into()], vec!["new".into()], true); let anchor = Anchor { revision: "working:snapshot".into(), path: "file".into(), side: Side::After, start: crate::plugin::TextPosition { row: 0, column: 0 }, end: crate::plugin::TextPosition { row: 1, column: 0 }, content: "new\n".into(), }; assert!(state.review_anchor_current(&anchor)); state.live_files.insert(0); assert!(!state.review_anchor_current(&anchor)); state.comparison.as_mut().unwrap().files[0] .after .push(String::new()); assert!(state.review_anchor_current(&anchor)); state.comparison.as_mut().unwrap().files[0].after[0] = "changed".into(); assert!(!state.review_anchor_current(&anchor)); state.comparison.as_mut().unwrap().files[0].old_path = Some("original".into()); let before = Anchor { revision: "base".into(), path: "original".into(), side: Side::Before, content: "old\n".into(), ..anchor }; assert!(state.review_anchor_current(&before)); state.comparison.as_mut().unwrap().files[0].loaded = false; assert!(!state.review_anchor_current(&before)); state.comparison.as_mut().unwrap().files[0].loaded = true; state.comparison.as_mut().unwrap().revisions[0] = "other".into(); assert!(!state.review_anchor_current(&before)); } #[test] fn million_row_additions_and_removals_keep_complete_sources_and_compact_navigation() { let lines = vec!["static const unsigned int parser_state = 1234567890;".to_owned(); 1_000_000]; let mut state = live_state(Vec::new(), lines, true); state.comparison.as_mut().unwrap().files[0].status = FileStatus::Added; state.align_file(); assert_eq!(state.rows.len(), 1_000_000); assert!(state.rows.iter().all(|row| row.changes.is_empty())); assert_eq!(state.source_ranges[1], [(0..1_000_000, 0)]); assert_eq!(state.after_display_row(999_999), Some(999_999)); assert_eq!( state.clamp_source_row(Focus::After, usize::MAX), Some(999_999) ); let mut visible = state.source_rows_in(Focus::After, 999_990, 999_999); assert_eq!(visible.next(), Some(999_990)); assert_eq!(visible.next_back(), Some(999_998)); drop(visible); let ColumnRow::Text(last) = state .column_view(Focus::After) .unwrap() .row(999_999) .unwrap() else { panic!("the final added source line must remain available"); }; assert_eq!( last.text, Some("static const unsigned int parser_state = 1234567890;") ); assert_eq!(last.source_row, 999_999); let file = &mut state.comparison.as_mut().unwrap().files[0]; std::mem::swap(&mut file.before, &mut file.after); file.status = FileStatus::Removed; file.hunks = line_hunks(&file.before, true, &file.after, false); assert_eq!( (file.hunks[0].before_len, file.hunks[0].after_len), (1_000_000, 0) ); state.align_file(); assert_eq!(state.source_ranges[0], [(0..1_000_000, 0)]); assert_eq!( state.source_display_row(Focus::Before, 999_999), Some(999_999) ); assert_eq!(state.source_display_row(Focus::After, 0), None); } #[test] fn large_localized_modifications_keep_exact_hunks_and_only_diff_visible_characters() { let before = vec!["unchanged generated parser body".to_owned(); 25_000]; let mut after = before.clone(); after[20_000] = "unchanged generated parser body!".into(); let state = live_state(before, after, true); let file = &state.comparison.as_ref().unwrap().files[0]; assert_eq!(file.hunks.len(), 1); let hunk = &file.hunks[0]; assert_eq!( ( hunk.before_start, hunk.before_len, hunk.after_start, hunk.after_len ), (20_000, 1, 20_000, 1) ); assert!(state.rows.iter().all(|row| row.changes.is_empty())); let display = state.after_display_row(20_000).unwrap(); let ColumnRow::Text(line) = state .column_view(Focus::After) .unwrap() .row(display) .unwrap() else { panic!("the modified source row must remain visible"); }; assert_eq!(line.changes().cloned().collect::>(), [31..32]); } #[test] fn pathological_modifications_preserve_every_line_when_diff_budget_is_exhausted() { let before = (0..25_000) .map(|row| format!("old {row}")) .collect::>(); let after = (0..25_000) .map(|row| format!("new {row}")) .collect::>(); let state = live_state(before, after, false); let file = &state.comparison.as_ref().unwrap().files[0]; let (mut old, mut new) = (0, 0); for hunk in &file.hunks { assert_eq!( &file.before[old..hunk.before_start], &file.after[new..hunk.after_start] ); old = hunk.before_start + hunk.before_len; new = hunk.after_start + hunk.after_len; } assert_eq!(&file.before[old..], &file.after[new..]); assert_eq!(state.rows.len(), 25_000); assert!( state .rows .iter() .all(|row| row.kind == RowKind::Changed && row.changes.is_empty()) ); assert_eq!(state.after_display_row(24_999), Some(24_999)); let giant = "界".repeat(64 * 1024); assert_eq!( character_changes(&giant, "replacement"), [[0..usize::MAX, 0..usize::MAX]] ); } #[test] fn indexed_source_ranges_match_projection_with_folds_gaps_and_unified_runs() { let before = (0..60).map(|row| format!("row {row}")).collect::>(); let mut after = before.clone(); after.splice(10..14, ["inserted".into()]); after.splice(35..35, ["extra 1".into(), "extra 2".into()]); let mut state = live_state(before, after, false); state.set_context_options(2, 5); for mode in [ViewMode::Split, ViewMode::Unified] { state.set_view_mode(mode); for focus in [Focus::Before, Focus::After] { let expected = (0..state.display_rows.len()) .filter_map(|display| { state .display_source_row(focus, display) .map(|row| (row, display)) }) .collect::>(); for source in 0..65 { assert_eq!( state.source_display_row(focus, source), expected .iter() .find(|(row, _)| *row == source) .map(|(_, display)| *display) ); assert_eq!( state.clamp_source_row(focus, source), expected .iter() .map(|(row, _)| *row) .min_by_key(|row| (row.abs_diff(source), *row)) ); let target = if focus == Focus::Before { Focus::After } else { Focus::Before }; let aligned = state.rows.iter().position(|row| match focus { Focus::Before => row.before == Some(source), _ => row.after == Some(source), }); let nearest = aligned.and_then(|aligned| { (0..state.display_rows.len()) .filter_map(|display| { let DisplayRow::Line(index) = state.display_rows[display] else { return None; }; state .display_source_row(target, display) .map(|row| (index.abs_diff(aligned), row)) }) .min() .map(|(_, row)| row) }); assert_eq!(state.corresponding_row(focus, target, source), nearest); } assert_eq!( state.source_rows_in(focus, 9, 40).collect::>(), expected .iter() .map(|(row, _)| *row) .filter(|row| (9..40).contains(row)) .collect::>() ); } } } #[test] fn ordinary_hunks_keep_similar_anchoring_for_repeated_lines_and_eof_changes() { let sources = (0..=3) .flat_map(|len| { (0..1 << len).map(move |bits| { (0..len) .map(|bit| { if bits & (1 << bit) == 0 { "a".into() } else { "b".into() } }) .collect::>() }) }) .collect::>(); for before in &sources { for after in &sources { for before_eof in [false, true] { for after_eof in [false, true] { let tokens = |lines: &[String], eof| { lines .iter() .enumerate() .map(|(index, line)| (line.clone(), index + 1 < lines.len() || eof)) .collect::>() }; let expected = similar::capture_diff_slices( similar::Algorithm::Myers, &tokens(before, before_eof), &tokens(after, after_eof), ) .into_iter() .filter_map(|op| { let (tag, old, new) = op.as_tag_tuple(); (tag != similar::DiffTag::Equal).then_some(( old.start, old.len(), new.start, new.len(), )) }) .collect::>(); let actual = line_hunks(before, before_eof, after, after_eof) .iter() .map(|hunk| { ( hunk.before_start, hunk.before_len, hunk.after_start, hunk.after_len, ) }) .collect::>(); assert_eq!( actual, expected, "{before:?} ({before_eof}) -> {after:?} ({after_eof})" ); } } } } }