refactor: hide logical comparison rows from painting

This commit is contained in:
Valdemar Lorenzen
2026-10-16 17:32:04 +02:00
parent a1fdc1e1b0
commit 4511bc2df9
5 changed files with 462 additions and 133 deletions
+140 -13
View File
@@ -13,7 +13,7 @@ fn conflict_history_preserves_source_gaps_base_changes_and_native_eof_rows() {
]);
let rows: Vec<_> = (0..history.row_count())
.map(|row| {
std::array::from_fn::<_, 3, _>(|column| history.column(column).source_row(row))
std::array::from_fn::<_, 3, _>(|column| history.column(column).view().native_row(row))
})
.collect();
assert_eq!(
@@ -26,7 +26,8 @@ fn conflict_history_preserves_source_gaps_base_changes_and_native_eof_rows() {
]
);
for column in 0..3 {
let projection = history.column(column);
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));
@@ -34,14 +35,26 @@ fn conflict_history_preserves_source_gaps_base_changes_and_native_eof_rows() {
}
}
let base = history.column(1);
let changed = &base.diff.rows[0].changes;
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!(changed.iter().any(|range| range[0].contains(&column)));
assert!(line.changes().any(|range| range.contains(&column)));
}
assert!(!changed.iter().any(|range| range[0].contains(&4)));
assert_eq!(base.diff.rows[2].kind, RowKind::Equal);
assert!(base.diff.rows[2].changes.is_empty());
assert_eq!(history.column(2).lines[1], "added");
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([
@@ -49,17 +62,17 @@ fn conflict_history_preserves_source_gaps_base_changes_and_native_eof_rows() {
"abc deg\nadded\nkeep\n",
"xbc def\nkeep\n",
]);
assert_eq!(history.column(1).source_row(1), Some(1));
assert_eq!(history.column(0).source_row(1), None);
assert_eq!(history.column(2).source_row(1), None);
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).source_row(row), Some(row));
assert_eq!(history.column(column).display_row(row), Some(row));
assert_eq!(history.column(column).view().native_row(row), Some(row));
assert_eq!(history.column(column).view().display_row(row), Some(row));
}
}
}
@@ -1815,6 +1828,120 @@ fn live_state(before: Vec<String>, after: Vec<String>, eof: bool) -> DiffState {
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);