fix: coalesce fragmented word-diff highlights

This commit is contained in:
Valdemar Lorenzen
2026-10-15 13:03:35 +02:00
parent 3a7e2074b4
commit 16490942d6
3 changed files with 131 additions and 9 deletions
+2 -1
View File
@@ -4,7 +4,8 @@
- it should be taller
- [x] add support for zig syntax highlighting
- [x] add the `*` action from vim on word to highlights and searches in buffer in one action.
- [ ] improve diff view: we should be able to tell what the part of the code we're looking at (any given change hunk) and where it belongs.
- [x] diff view: improve word-diff highlighting
- [ ] diff view: we should be able to tell what the part of the code we're looking at (any given change hunk) and where it belongs.
- i think it would be nice if we in the diff view somehow displayed the function name and/or class name that a hunk belongs to
- we should consider the ui before implementing this
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
+1 -1
View File
@@ -452,7 +452,7 @@ In split diffs, changed text rows use muted red backgrounds in Before and muted
The row tint also covers the line-number gutter on wrapped continuations and blank wrap-padding rows on the shorter side of an aligned pair. Empty source lines keep the same full-row tint; missing source counterparts retain their normal-background hatching.
Character-wise highlighting is enabled automatically within paired changed lines: only changed graphemes receive a stronger red/green background, while common text retains the muted row tint. Combining sequences, wide characters, and expanded tabs keep their source geometry through horizontal scrolling and word wrap. Syntax/semantic foregrounds remain intact, and cursor/search/selection styles keep their normal precedence. Character ranges are cached with aligned rows; stable live edits refresh only the edited paired lines rather than computing character diffs during painting.
Character-wise highlighting is enabled automatically within paired changed lines, with a stronger red/green background over changed grapheme spans. To reduce fragmented highlighting, changes separated by at most two unchanged graphemes are merged; short unchanged islands between large edits are also absorbed using VS Code-style size heuristics. Larger common regions retain the muted row tint. Combining sequences, wide characters, and expanded tabs keep their source geometry through horizontal scrolling and word wrap. Syntax/semantic foregrounds remain intact, and cursor/search/selection styles keep their normal precedence. Character ranges are cached with aligned rows; stable live edits refresh only the edited paired lines rather than computing character diffs during painting.
Diff backgrounds blend the terminal's default background with its red/green palette colors: an 18% accent for changed rows and a stronger 38% accent for changed characters, both computed from the original background. Read-only OSC 11/4 queries run after the initial paint, on focus regain, and whenever a subscribed DEC 2031 color-scheme notification arrives. While a diff is visible in any pane, a nonblocking one-second palette probe also catches external theme changes without a notification, including changes between two themes with the same light/dark preference. Ordinary editor views do not poll; cached colors apply across editor panes. If the complete palette is unavailable within 250 ms, change backgrounds remain normal and gutter colors/markers still identify changes.
+126 -5
View File
@@ -2241,13 +2241,83 @@ fn character_changes(before: &str, after: &str) -> Vec<[std::ops::Range<usize>;
}
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 mut changes: Vec<[std::ops::Range<usize>; 2]> = Vec::new();
let (mut before_offset, mut after_offset) = (0, 0);
for op in similar::capture_diff_slices(similar::Algorithm::Myers, &before, &after) {
let (tag, before, after) = op.as_tag_tuple();
(tag != similar::DiffTag::Equal).then_some([before, after])
// similar's optimized empty-range anchors can be stale; consume lengths in order.
let before = before_offset..before_offset + before.len();
let after = after_offset..after_offset + after.len();
(before_offset, after_offset) = (before.end, after.end);
if tag == similar::DiffTag::Equal {
continue;
}
if let Some(previous) = changes.last_mut()
&& (before.start - previous[0].end <= 2 || after.start - previous[1].end <= 2)
{
previous[0].end = before.end;
previous[1].end = after.end;
} else {
changes.push([before, after]);
}
}
// A merge can enable an earlier gap; VS Code bounds this pass to 11 sweeps.
for _ in 0..11 {
let mut kept = 1;
for index in 1..changes.len() {
if short_match_between_large_changes(&before, &changes[kept - 1], &changes[index]) {
changes[kept - 1][0].end = changes[index][0].end;
changes[kept - 1][1].end = changes[index][1].end;
} else {
if kept != index {
changes[kept] = changes[index].clone();
}
kept += 1;
}
}
if kept >= changes.len() {
break;
}
changes.truncate(kept);
}
changes
}
fn short_match_between_large_changes(
before: &[&str],
previous: &[std::ops::Range<usize>; 2],
next: &[std::ops::Range<usize>; 2],
) -> bool {
let gap = &before[previous[0].end..next[0].start];
if gap.len() > 500 {
return false;
}
let start = gap
.iter()
.position(|grapheme| !grapheme.trim().is_empty())
.unwrap_or(gap.len());
let trimmed_len = gap[start..]
.iter()
.rposition(|grapheme| !grapheme.trim().is_empty())
.map_or(0, |end| end + 1);
if trimmed_len > 20 {
return false;
}
// VS Code's size heuristic, specialized to single lines and normalized by 130^2.25:
// https://github.com/microsoft/vscode/blob/main/src/vs/editor/common/diff/defaultLinesDiffComputer/heuristicSequenceOptimizations.ts
let score = |change: &[std::ops::Range<usize>; 2]| {
let weight = change
.iter()
.map(|range| {
let length = range.len().min(130) as f64 / 130.0;
length * length.sqrt()
})
.collect()
.sum::<f64>();
weight * weight.sqrt()
};
score(previous) + score(next) > 1.3
}
fn line_width(line: &str) -> usize {
@@ -4522,6 +4592,57 @@ mod tests {
assert!(state.rows[0].changes.is_empty());
}
#[test]
fn character_highlights_merge_incidental_matches_but_preserve_meaningful_gaps() {
let mut cases = vec![
("a..c".into(), "b..d".into(), vec![[0..4, 0..4]]),
("base".into(), "edited".into(), vec![[0..4, 0..6]]),
(
"a...c".into(),
"b...d".into(),
vec![[0..1, 0..1], [4..5, 4..5]],
),
(
"a界e\u{301}c".into(),
"b界e\u{301}d".into(),
vec![[0..4, 0..4]],
),
];
for (size, gap, merge) in [
(60, " = ".to_owned(), false),
(70, " = ".to_owned(), true),
(70, "x".repeat(20), true),
(70, "unchanged_expression".to_owned(), true),
(70, "unchanged_expression_".to_owned(), false),
(70, " ".repeat(500), true),
(70, " ".repeat(501), false),
] {
let end = 2 * size + gap.len();
let second = size + gap.len();
cases.push((
format!("{}{}{}", "a".repeat(size), gap, "c".repeat(size)),
format!("{}{}{}", "b".repeat(size), gap, "d".repeat(size)),
if merge {
vec![[0..end, 0..end]]
} else {
vec![[0..size, 0..size], [second..end, second..end]]
},
));
}
cases.push((
format!("aaa = {} = {}", "c".repeat(70), "e".repeat(70)),
format!("bbb = {} = {}", "d".repeat(70), "f".repeat(70)),
vec![[0..149, 0..149]],
));
for (before, after, expected) in cases {
let mut state = live_state(vec![before], vec![after.clone()], false);
assert_eq!(state.rows[0].changes, expected, "{after:?}");
state.refresh_after(&[format!("{after}!")], 1);
state.refresh_after(std::slice::from_ref(&after), 2);
assert_eq!(state.rows[0].changes, expected, "live edit: {after:?}");
}
}
#[test]
fn reverting_loaded_file_keeps_unloaded_changes_selectable() {
let mut state = live_state(vec!["base".into()], vec!["changed".into()], false);