feat: highlight character-level changes in diff panes
This commit is contained in:
+59
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user