feat: highlight character-level changes in diff panes

This commit is contained in:
2026-10-11 20:01:43 +02:00
parent a69372fb6e
commit a80f97309b
5 changed files with 145 additions and 36 deletions
+59
View File
@@ -154,6 +154,8 @@ pub struct AlignedRow {
pub before: Option<usize>,
pub after: Option<usize>,
pub kind: RowKind,
/// Changed grapheme ranges, paired as [before, after]; empty on unpaired rows.
pub changes: Vec<[std::ops::Range<usize>; 2]>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -958,6 +960,7 @@ impl DiffState {
lines,
changed_lines,
);
refresh_character_changes(&mut self.rows, file, changed_lines.0..changed_lines.2);
self.left_after = self.left_after.min(self.max_after);
self.live_revision = Some(revision);
return;
@@ -1150,8 +1153,10 @@ impl DiffState {
before: None,
after: Some(after_len),
kind: RowKind::Equal,
changes: Vec::new(),
});
}
refresh_character_changes(&mut self.rows, file, 0..file.after.len());
self.reset_excerpts();
}
@@ -1310,10 +1315,43 @@ fn append_rows(
before,
after,
kind,
changes: Vec::new(),
});
}
}
fn refresh_character_changes(
rows: &mut [AlignedRow],
file: &ChangedFile,
after_rows: std::ops::Range<usize>,
) {
for row in rows {
if row.kind == RowKind::Changed
&& let Some(after) = row.after.filter(|index| after_rows.contains(index))
{
row.changes = character_changes(
&file.before[row.before.expect("paired changed row")],
&file.after[after],
);
}
}
}
fn character_changes(before: &str, after: &str) -> Vec<[std::ops::Range<usize>; 2]> {
if before == after {
return Vec::new();
}
let before: Vec<_> = before.graphemes(true).collect();
let after: Vec<_> = after.graphemes(true).collect();
similar::capture_diff_slices(similar::Algorithm::Myers, &before, &after)
.into_iter()
.filter_map(|op| {
let (tag, before, after) = op.as_tag_tuple();
(tag != similar::DiffTag::Equal).then_some([before, after])
})
.collect()
}
fn line_width(line: &str) -> usize {
#[cfg(test)]
tests::WIDTH_SCANS.with(|scans| scans.set(scans.get() + 1));
@@ -1502,6 +1540,7 @@ mod tests {
} else {
RowKind::Equal
},
changes: Vec::new(),
})
.collect();
state.reset_excerpts();
@@ -2631,6 +2670,26 @@ mod tests {
state
}
#[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 reverting_loaded_file_keeps_unloaded_changes_selectable() {
let mut state = live_state(vec!["base".into()], vec!["changed".into()], false);
+82 -34
View File
@@ -2483,6 +2483,19 @@ fn draw_diff_pane(
}
paint_syntax_line(frame, source_text, syntax.line(index), text_area, left);
paint_semantic_line(frame, source_text, semantic, index, text_area, left);
if !matches!(file.status, FileStatus::Added | FileStatus::Removed) {
let style = Style::default().bg(if before {
Color::Rgb(112, 40, 40)
} else {
Color::Rgb(32, 88, 48)
});
for change in &row.changes {
let range = &change[usize::from(!before)];
if !range.is_empty() {
paint_grapheme_range(frame, text_area, left, source_text, range.clone(), style);
}
}
}
}
Rect::new(
content.x + gutter as u16,
@@ -3869,11 +3882,9 @@ mod tests {
let after = panels.diff.as_ref().unwrap().regions[2].0;
let cell = &terminal.backend().buffer()[(after.x + 4, after.y + 1)];
assert_eq!(cell.fg, foreground, "{source}");
assert_eq!(cell.bg, Color::Rgb(24, 48, 32));
let before = panels.diff.as_ref().unwrap().regions[1].0;
let cell = &terminal.backend().buffer()[(before.x + 4, before.y + 1)];
assert_eq!(cell.fg, Color::Magenta);
assert_eq!(cell.bg, Color::Rgb(64, 24, 24));
}
}
@@ -3909,19 +3920,12 @@ mod tests {
assert!(syntax.before.parse_counts().1 > 0);
assert_eq!(syntax.before.line(1)[0].kind, SyntaxKind::Keyword);
assert_eq!(syntax.after.line(1)[0].kind, SyntaxKind::Keyword);
for (region, background) in [
(
panels.diff.as_ref().unwrap().regions[1].0,
Color::Rgb(64, 24, 24),
),
(
panels.diff.as_ref().unwrap().regions[2].0,
Color::Rgb(24, 48, 32),
),
for region in [
panels.diff.as_ref().unwrap().regions[1].0,
panels.diff.as_ref().unwrap().regions[2].0,
] {
let cell = &terminal.backend().buffer()[(region.x + 4, region.y + 2)];
assert_eq!(cell.fg, Color::Magenta);
assert_eq!(cell.bg, background);
}
}
@@ -4146,6 +4150,71 @@ mod tests {
.unwrap();
}
#[test]
fn character_diff_backgrounds_preserve_common_unicode_and_syntax_across_viewports() {
let mut diff = diff_comparison(vec![syntax_diff_file(
"changes.rs",
"let 界e\u{301} = old + old;",
"let 界e\u{301} = new + new;",
)]);
diff.files_visible = false;
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
for (side, base, strong) in [
(1, Color::Rgb(64, 24, 24), Color::Rgb(112, 40, 40)),
(2, Color::Rgb(24, 48, 32), Color::Rgb(32, 88, 48)),
] {
let pane = panels.diff.as_ref().unwrap().regions[side].0;
let buffer = terminal.backend().buffer();
for column in [0, 3, 4, 6, 8, 14, 19] {
assert_eq!(buffer[(pane.x + 4 + column, pane.y + 1)].bg, base);
}
for column in [10, 11, 12, 16, 17, 18] {
assert_eq!(buffer[(pane.x + 4 + column, pane.y + 1)].bg, strong);
}
assert_eq!(buffer[(pane.x + 4, pane.y + 1)].fg, Color::Magenta);
assert_eq!(buffer[(pane.x + 8, pane.y + 1)].symbol(), "界");
assert_eq!(buffer[(pane.x + 10, pane.y + 1)].symbol(), "e\u{301}");
}
diff.left_before = 11;
diff.left_after = 11;
render_diff(&mut terminal, &diff, &mut panels);
let after = panels.diff.as_ref().unwrap().regions[2].0;
assert_eq!(
terminal.backend().buffer()[(after.x + 4, after.y + 1)].symbol(),
"e"
);
assert_eq!(
terminal.backend().buffer()[(after.x + 4, after.y + 1)].bg,
Color::Rgb(32, 88, 48)
);
assert_eq!(
terminal.backend().buffer()[(after.x + 7, after.y + 1)].bg,
Color::Rgb(24, 48, 32)
);
diff.word_wrap = true;
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 12)).unwrap();
render_diff(&mut terminal, &diff, &mut panels);
let after = panels.diff.as_ref().unwrap().regions[2].0;
let buffer = terminal.backend().buffer();
let mut words = 0;
for y in after.y + 1..after.bottom() - 1 {
for x in after.x + 4..after.right().saturating_sub(3) {
if buffer[(x, y)].symbol() == "n"
&& buffer[(x + 1, y)].symbol() == "e"
&& buffer[(x + 2, y)].symbol() == "w"
{
words += 1;
for column in x..x + 3 {
assert_eq!(buffer[(column, y)].bg, Color::Rgb(32, 88, 48));
}
}
}
}
assert_eq!(words, 2);
}
#[test]
fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage() {
let mut diff = DiffState::new();
@@ -4404,24 +4473,7 @@ mod tests {
let before = geometry.regions[1].0;
let after = geometry.regions[2].0;
let buffer = terminal.backend().buffer();
for (pane, background) in [
(before, Color::Rgb(64, 24, 24)),
(after, Color::Rgb(24, 48, 32)),
] {
for row in 0..5 {
// TestBackend emits the wide glyph, not its continuation cells.
let mut x = pane.x + 1;
while x < pane.right() - 1 {
assert_eq!(
buffer[(x, pane.y + 1 + row)].bg,
background,
"pane={pane:?} x={x} row={row} symbol={:?}",
buffer[(x, pane.y + 1 + row)].symbol()
);
x += UnicodeWidthStr::width(buffer[(x, pane.y + 1 + row)].symbol()).max(1)
as u16;
}
}
for pane in [before, after] {
for (column, row, foreground) in [
(0, 0, Color::Magenta),
(3, 0, Color::LightBlue),
@@ -5805,10 +5857,6 @@ mod tests {
terminal.backend().buffer()[(text_x, caret.y)].fg,
Color::DarkGray
);
assert_eq!(
terminal.backend().buffer()[(text_x, caret.y)].bg,
Color::Rgb(24, 48, 32)
);
editor.handle(Input::Home);
editor.handle(Input::Char('v'));
editor.handle(Input::Right);