fix: coalesce fragmented word-diff highlights
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
|
||||
+128
-7
@@ -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 (tag, before, after) = op.as_tag_tuple();
|
||||
(tag != similar::DiffTag::Equal).then_some([before, after])
|
||||
})
|
||||
.collect()
|
||||
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();
|
||||
// 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()
|
||||
})
|
||||
.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);
|
||||
|
||||
Reference in New Issue
Block a user