feat: show base-relative changes in conflict panes

This commit is contained in:
2026-10-24 23:55:12 +02:00
parent c88582f2ba
commit c1bff63111
9 changed files with 509 additions and 322 deletions
+46 -9
View File
@@ -19,7 +19,8 @@ fn conflict_history_preserves_source_gaps_base_changes_and_native_eof_rows() {
assert_eq!(
rows,
[
[Some(0), Some(0), Some(0)],
[None, Some(0), None],
[Some(0), None, Some(0)],
[None, None, Some(1)],
[Some(1), Some(1), Some(2)],
[Some(2), Some(2), Some(3)],
@@ -44,13 +45,13 @@ fn conflict_history_preserves_source_gaps_base_changes_and_native_eof_rows() {
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 {
let ColumnRow::Text(line) = base.row(3).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 {
let ColumnRow::Text(line) = right.view().row(2).unwrap() else {
panic!("inserted right row must contain text");
};
assert_eq!(line.text, Some("added"));
@@ -62,9 +63,45 @@ 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).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);
assert_eq!(history.column(1).view().native_row(2), Some(1));
assert_eq!(history.column(0).view().native_row(2), None);
assert_eq!(history.column(2).view().native_row(2), None);
// Deletions are visible on either side, but never become editable source rows.
let history = HistoryComparison::new(["keep\n", "old\nkeep\n", "new\nkeep\n"]);
for column in [0, 2] {
let projection = history.column(column).view();
let ColumnRow::Text(line) = projection.row(0).unwrap() else {
panic!("removed base text must remain visible");
};
assert_eq!(line.text, Some("old"));
assert!(line.changed);
assert_eq!(line.polarity, Focus::Before);
assert!(!line.native);
assert_eq!(projection.native_row(0), None);
assert_eq!(projection.text_row(0), Some((usize::MAX, "old")));
}
assert!(matches!(
history.column(0).view().row(1),
Some(ColumnRow::Gap)
));
let right = history.column(2).view();
let ColumnRow::Text(line) = right.row(1).unwrap() else {
panic!("replacement must be an addition");
};
assert_eq!(line.text, Some("new"));
assert_eq!(line.polarity, Focus::After);
assert_eq!(right.native_row(1), Some(0));
assert_eq!(right.display_row(0), Some(1));
let history = HistoryComparison::new(["left", "", "right"]);
for column in [0, 2] {
let projection = history.column(column).view();
let ColumnRow::Text(line) = projection.row(0).unwrap() else {
panic!("add/add without a base must show additions on both sides");
};
assert!(line.changed && line.native);
assert_eq!(line.polarity, Focus::After);
assert_eq!(projection.native_row(0), Some(0));
}
for text in ["", "\n", "same", "same\n"] {
let history = HistoryComparison::new([text; 3]);
let native_rows = text.split('\n').count();
@@ -83,7 +120,7 @@ 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);
state.set_resolver_text(&lines, &lines, None);
assert_eq!(state.pane_layout(), PaneLayout::Split);
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty());
assert_eq!(
@@ -99,7 +136,7 @@ fn resolver_equal_text_retains_native_rows_and_cached_viewport() {
state.top = 1;
state.left_before = 3;
state.left_after = 4;
state.set_resolver_text(&lines, &lines);
state.set_resolver_text(&lines, &lines, None);
assert_eq!(state.comparison_id, id);
assert_eq!((state.top, state.left_before, state.left_after), (1, 3, 4));
}
@@ -2407,7 +2444,7 @@ 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);
state.set_resolver_text(&lines, &lines, None);
for focus in [Focus::Before, Focus::After] {
let view = state.column_view(focus).unwrap();
let native = lines.len() - 1;
+4 -7
View File
@@ -122,7 +122,10 @@ fn resolver_shares_native_excerpt_search_motions_and_explicit_writable_side() {
}
let draft = before.join("\n");
let alternative = after.join("\n");
let mut resolver = resolver_editor(&draft, &[(&draft, false), (&alternative, false)]);
let mut resolver = resolver_editor(
&draft,
&[(&draft, false), (&alternative, false), (&draft, true)],
);
let mut ordinary = editor();
historical_diff(&mut ordinary, &directory.0, before, after);
for editor in [&mut resolver, &mut ordinary] {
@@ -161,10 +164,6 @@ fn resolver_shares_native_excerpt_search_motions_and_explicit_writable_side() {
.is_some()
);
}
assert_eq!(
resolver.diff().unwrap().display_rows,
ordinary.diff().unwrap().display_rows
);
assert!(resolver.diff_editing());
assert!(resolver.diff().unwrap().side_editable(Focus::Before));
assert!(!resolver.diff().unwrap().side_editable(Focus::After));
@@ -397,7 +396,6 @@ fn resolver_history_selects_every_side_and_base_without_moving_draft_or_viewport
editor.set_diff_viewport(diff.comparison_id.unwrap(), diff.file, 8, 2, 4);
let cursor = editor.cursor();
let buffer = editor.buffer_id();
let id = editor.resolver_diff().unwrap().comparison_id;
assert!(editor.file_picker().is_none());
editor.execute("word_wrap.toggle");
assert!(editor.word_wrap());
@@ -419,7 +417,6 @@ fn resolver_history_selects_every_side_and_base_without_moving_draft_or_viewport
assert_eq!(editor.cursor(), cursor);
assert_eq!(editor.buffer_id(), buffer);
let diff = editor.resolver_diff().unwrap();
assert_eq!(diff.comparison_id, id);
assert_eq!((diff.top, diff.left_before, diff.left_after), (8, 2, 4));
assert!(editor.word_wrap());
editor.handle(Input::Char('\t'));
+71 -166
View File
@@ -1286,165 +1286,47 @@ fn resolver_renders_aligned_native_draft_alternative_and_separate_history() {
let content = diff_inner(area);
let x = content.right() - geometry.text_widths[side];
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(x, content.y)].symbol(), "v");
assert_eq!(
buffer[(x, content.y)].bg,
views.panels.diff_colors.row[side]
);
assert_eq!(buffer[(x + 8, content.y)].symbol(), "b");
assert_eq!(buffer[(x, content.y)].bg, views.panels.diff_colors.row[0]);
assert_eq!(
buffer[(x + 8, content.y)].bg,
views.panels.diff_colors.character[side]
views.panels.diff_colors.character[0]
);
assert_eq!(buffer[(x, content.y + 1)].symbol(), "v");
assert_eq!(
buffer[(x, content.y + 1)].bg,
views.panels.diff_colors.row[1]
);
}
assert_ne!(
line_at(&terminal, diff_inner(draft), draft.y + 1),
line_at(&terminal, diff_inner(alternative), alternative.y + 1)
line_at(&terminal, diff_inner(draft), draft.y + 2),
line_at(&terminal, diff_inner(alternative), alternative.y + 2)
);
for area in [draft, alternative] {
assert!(
line_at(&terminal, diff_inner(area), area.y + 2).contains("opening context 00"),
line_at(&terminal, diff_inner(area), area.y + 3).contains("opening context 00"),
"Preexisting unsaved working-buffer edits must appear in both projections"
);
}
let diff = session.editor().diff().unwrap();
let file = &diff.comparison.as_ref().unwrap().files[diff.file];
let mut reference_file = syntax_diff_file(
&file.path.to_string_lossy(),
&file.before.join("\n"),
&file.after.join("\n"),
);
reference_file.before_eof = file.before_eof;
reference_file.after_eof = file.after_eof;
reference_file.hunks = crate::diff::line_hunks(
&reference_file.before,
reference_file.before_eof,
&reference_file.after,
reference_file.after_eof,
);
let mut reference = Editor::open(None, vec![]).unwrap();
for _ in 1..session.editor().tab_count() {
reference.execute("tab.new");
}
reference
.set_chrome_config(
session.editor().top_bar_config().clone(),
session.editor().bottom_bar_config().clone(),
BTreeMap::new(),
)
.unwrap();
install_readonly_diff(&mut reference, reference_file);
reference.handle(Input::Ctrl('w'));
let cursor = session.editor().cursor();
for ch in format!("{}g", cursor.row + 1).chars() {
reference.handle(Input::Char(ch));
}
reference.handle(Input::Home);
for _ in 0..cursor.column {
reference.handle(Input::Right);
}
assert_eq!(reference.cursor(), cursor);
assert_eq!(
reference.diff_buffer_focus(),
session.editor().diff_buffer_focus()
);
let mut reference_terminal =
Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
let mut reference_views = BufferViewports::default();
reference_views.set_theme(Some(DARK_DIFF_THEME));
for command in [
None,
Some("diff.orientation.toggle"),
Some("diff.view.toggle"),
Some("diff.view.toggle"),
Some("diff.orientation.toggle"),
"diff.orientation.toggle",
"diff.view.toggle",
"diff.view.toggle",
"diff.orientation.toggle",
] {
if let Some(command) = command {
session.execute(command);
reference.execute(command);
}
session.execute(command);
render(&mut terminal, &mut views, &mut session);
render_native_diff(
&mut reference_terminal,
&mut reference_views,
&mut reference,
);
let actual = views.panels.diff.as_ref().unwrap();
let expected = reference_views.panels.diff.as_ref().unwrap();
assert_eq!(actual.view_mode, expected.view_mode);
assert_eq!(actual.split_axis, expected.split_axis);
assert_eq!(actual.text_widths, expected.text_widths);
for (side, ((actual_pane, _), (expected_pane, _))) in actual.regions[1..]
.iter()
.zip(&expected.regions[1..])
.enumerate()
{
assert_eq!(
(
actual_pane.x,
actual_pane.y,
actual_pane.width,
actual_pane.height
),
(
expected_pane.x,
expected_pane.y,
expected_pane.width,
expected_pane.height
)
);
let actual_text = Rect {
x: diff_inner(*actual_pane).right() - actual.text_widths[side],
width: actual.text_widths[side],
..diff_inner(*actual_pane)
};
let expected_text = Rect {
x: diff_inner(*expected_pane).right() - expected.text_widths[side],
width: expected.text_widths[side],
..diff_inner(*expected_pane)
};
for row in 0..actual_text.height {
assert_eq!(
line_at(&terminal, actual_text, actual_text.y + row),
line_at(&reference_terminal, expected_text, expected_text.y + row,),
"{command:?}, row {row}, full rows: {:?} / {:?}",
line_at(&terminal, diff_inner(*actual_pane), actual_text.y + row),
line_at(
&reference_terminal,
diff_inner(*expected_pane),
expected_text.y + row,
),
);
}
}
let screen = screen_text(&terminal);
assert!(screen.contains("value = base;"));
assert!(screen.contains("value = left;"));
assert!(screen.contains("value = right;"));
assert_eq!(session.editor().lines(), original_draft);
}
let folded_context_rows = screen_text(&terminal).matches("context").count();
session.handle(Input::Char(']'));
reference.handle(Input::Char(']'));
render(&mut terminal, &mut views, &mut session);
render_native_diff(
&mut reference_terminal,
&mut reference_views,
&mut reference,
);
let folds = |editor: &Editor| {
editor
.diff()
.unwrap()
.display_rows
.iter()
.filter_map(|row| match row {
DisplayRow::Fold { start, end } => Some((*start, *end)),
_ => None,
})
.collect::<Vec<_>>()
};
assert_eq!(folds(session.editor()), folds(&reference));
let expanded_context_rows = screen_text(&terminal).matches("context").count();
assert!(expanded_context_rows > folded_context_rows);
assert_eq!(
expanded_context_rows,
screen_text(&reference_terminal).matches("context").count()
);
let third_source = session.editor().resolver().unwrap().files[current_file]
.sources
.iter()
@@ -1453,7 +1335,7 @@ fn resolver_renders_aligned_native_draft_alternative_and_separate_history() {
session.execute(&format!("resolve.source {}", third_source + 1));
render(&mut terminal, &mut views, &mut session);
assert!(
line_at(&terminal, diff_inner(alternative), alternative.y + 1).contains("value = third;")
line_at(&terminal, diff_inner(alternative), alternative.y + 2).contains("value = third;")
);
let right_content = diff_inner(alternative);
assert!(
@@ -1516,9 +1398,15 @@ fn resolver_renders_aligned_native_draft_alternative_and_separate_history() {
assert!(left_heading.contains("unsaved"));
assert!(!right_heading.contains("unsaved"));
assert!(right_heading.contains("read-only"));
assert!(line_at(&terminal, left, left.y + 1).contains("manual opening context 00"));
assert!(line_at(&terminal, right, right.y + 1).contains("opening context 00"));
assert!(!line_at(&terminal, right, right.y + 1).contains("manual"));
assert!(
(left.y..left.bottom())
.any(|y| line_at(&terminal, left, y).contains("manual opening context 00"))
);
assert!(
(right.y..right.bottom())
.any(|y| line_at(&terminal, right, y).contains("opening context 00"))
);
assert!(!(right.y..right.bottom()).any(|y| line_at(&terminal, right, y).contains("manual")));
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), original_draft);
session.handle(Input::Up);
@@ -1637,7 +1525,11 @@ fn resolver_renders_aligned_native_draft_alternative_and_separate_history() {
let source_rows: Vec<_> = views
.rects
.iter()
.map(|(_, area)| line_at(&terminal, *area, area.y))
.map(|(_, area)| {
(area.y..area.bottom())
.map(|y| line_at(&terminal, *area, y))
.collect::<String>()
})
.collect();
assert!(source_rows.iter().any(|row| row.contains("base")));
assert!(source_rows.iter().any(|row| row.contains("left")));
@@ -1645,19 +1537,15 @@ fn resolver_renders_aligned_native_draft_alternative_and_separate_history() {
let history = &views.history[&views.history_key.unwrap()];
for (column, (_, content)) in views.rects.iter().enumerate() {
let x = content.right() - history.widths[column] as u16;
let side = usize::from(column != 1);
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(x, content.y)].symbol(), "v");
assert_eq!(
buffer[(x, content.y)].bg,
views.panels.diff_colors.row[side]
);
assert_eq!(buffer[(x, content.y)].bg, views.panels.diff_colors.row[0]);
assert_eq!(
buffer[(x + 8, content.y)].bg,
views.panels.diff_colors.character[side]
views.panels.diff_colors.character[0]
);
assert_eq!(
buffer[(x, content.y + 1)].bg,
buffer[(x, content.y + 2)].bg,
Color::Reset,
"Shared historical context must not be highlighted as changed"
);
@@ -1672,22 +1560,21 @@ fn resolver_renders_aligned_native_draft_alternative_and_separate_history() {
session.editor_mut().focus_resolver_pane(right_history);
session.execute(&format!("resolve.source {}", third_source + 1));
render(&mut terminal, &mut views, &mut session);
assert!(
views
.rects
.iter()
.any(|(_, area)| { line_at(&terminal, *area, area.y).contains("value = third;") })
);
assert!(views.rects.iter().any(|(_, area)| {
(area.y..area.bottom()).any(|y| line_at(&terminal, *area, y).contains("value = third;"))
}));
let history_areas = [views.rects[0].1, views.rects[1].1, views.rects[2].1];
let insertion_y = (history_areas[2].y..history_areas[2].bottom())
.find(|&y| line_at(&terminal, history_areas[2], y).contains("third-only"))
.unwrap();
for content in history_areas.into_iter().take(2) {
assert!(
line_at(&terminal, content, content.y + 1)
line_at(&terminal, content, insertion_y)
.chars()
.all(|ch| ch == '╱'),
"The historical input insertion must leave aligned gaps in input/base"
);
}
assert!(line_at(&terminal, history_areas[2], history_areas[2].y + 1).contains("third-only"));
let context_rows: Vec<_> = history_areas
.into_iter()
.map(|content| {
@@ -1734,9 +1621,18 @@ fn resolver_renders_aligned_native_draft_alternative_and_separate_history() {
)
));
render(&mut terminal, &mut views, &mut session);
for content in history_areas {
assert!(line_at(&terminal, content, content.y).contains("context 01"));
}
let context: Vec<_> = history_areas
.into_iter()
.map(|content| {
line_at(&terminal, content, content.y)
.split_once("context ")
.unwrap()
.1
.trim_end()
.to_owned()
})
.collect();
assert!(context.iter().all(|line| line == &context[0]));
assert_eq!(session.editor().pane_id(), history_pane);
assert_eq!(session.editor().cursor(), history_cursor);
assert!(views.scroll(
@@ -1801,7 +1697,7 @@ fn resolver_renders_aligned_native_draft_alternative_and_separate_history() {
(right_area, "value = right;"),
(base_area, "value = base;"),
] {
assert!(line_at(&terminal, area, area.y).contains(value));
assert!((area.y..area.bottom()).any(|y| line_at(&terminal, area, y).contains(value)));
}
bottom.handle(Input::Char('\t'));
assert_eq!(bottom.editor().pane_id(), right);
@@ -1818,9 +1714,18 @@ fn resolver_renders_aligned_native_draft_alternative_and_separate_history() {
document_wheel(MouseEventKind::ScrollDown, base_area.x, base_area.y)
));
render(&mut terminal, &mut views, &mut bottom);
for area in [left_area, right_area, base_area] {
assert!(line_at(&terminal, area, area.y).contains("context 02"));
}
let context: Vec<_> = [left_area, right_area, base_area]
.into_iter()
.map(|area| {
line_at(&terminal, area, area.y)
.split_once("context ")
.unwrap()
.1
.trim_end()
.to_owned()
})
.collect();
assert!(context.iter().all(|line| line == &context[0]));
let readonly = bottom.editor().context().text;
bottom.handle(Input::Char('i'));
bottom.handle(Input::Paste("must not edit base".into()));