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);