adbd2c3f81
Bound synchronous full-source syntax parsing, cache dirty comparisons, edit ordinary inline changes in place, retain changed-row undo history, and buffer atomic saves. Skip repository snapshots beyond existing worker limits before copying their text.
2753 lines
97 KiB
Rust
2753 lines
97 KiB
Rust
use super::*;
|
|
use crate::editor::{Editor, Effect, Mode};
|
|
std::thread_local! {
|
|
pub(super) static WIDTH_SCANS: std::cell::Cell<usize> = 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::<Vec<_>>());
|
|
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::<Vec<_>>(),
|
|
["#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::<Vec<_>>(),
|
|
["#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::<Vec<_>>(),
|
|
[
|
|
&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::<Vec<_>>(),
|
|
["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::<Vec<_>>(),
|
|
[
|
|
"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::<Vec<_>>(),
|
|
[
|
|
(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::<Vec<_>>();
|
|
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::<Vec<_>>(),
|
|
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<String>, after: Vec<String>, 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::<Vec<_>>(), [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 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(focus) {
|
|
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(Focus::After).collect();
|
|
state.set_view_mode(ViewMode::Split);
|
|
state.set_view_mode(ViewMode::Unified);
|
|
assert_eq!(state.source_rows(Focus::After).collect::<Vec<_>>(), 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::<Vec<_>>(),
|
|
[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::<Vec<_>>();
|
|
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::<Vec<_>>()
|
|
};
|
|
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::<Vec<_>>();
|
|
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));
|
|
}
|