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
+1 -1
View File
@@ -5,7 +5,7 @@
- shared split layout and directional focus with diff views; independent pane views, shared buffers, and guarded close/force-close actions.
- [x] remove diff viewer's file explorer's `A added M modified...` explanation - it's standard and the explanation doesn't fit in the pane, so it can go.
- no replacement help footer in diff file lists; reclaim its row for file entries.
- [ ] diff view: add character-wise change highlight (enabled by default).
- [x] diff view: add character-wise change highlight (enabled by default).
- [ ] add tabs: tab support, editor action to open a buffer in a new tab, default tab navigation is `ctrl+option+h/l`, close tab action.
- [ ] diff view: lsp actions should act as they normally would. if an lsp action would open another file, then the file should be opened in another tab outside the diff view - if the referenced file/symbol is part of the changes in the diff, it should just be opened in the diff view.
- this will require tab support.
+1 -1
View File
@@ -11,7 +11,7 @@ One crate; no workspace or terminal-coordinate types in the editing backend:
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, key-sequence/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
Virtual file leaves retain their original catalog input index for caller-owned annotations; scanned entries and directories remain unannotated.
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, and aligned rows/shared vertical and independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
- `repository`: discovers jj/Git repositories and local/advertised remote revisions, builds disk snapshots and changed-file hunks, and owns isolated temporary comparison repositories with bounded, cancellable subprocesses. It never saves editor buffers or modifies active refs/checkouts.
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
- `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and syntax byte spans; it has no terminal color/geometry or editing dependency.
+2
View File
@@ -298,6 +298,8 @@ Diff views inherit the originating buffer's word-wrap setting. `word_wrap.toggle
In split diffs, changed text rows use muted red backgrounds in Before and muted green backgrounds in After, including blank tails and real empty lines. Entirely added or deleted files retain the normal background; colored line numbers and `+`/`-` gutter markers indicate their changes. Foreground colors use the editor's bundled Tree-sitter syntax highlighting, parsed independently for each snapshot and its path (including the old path for renames). Native text panes also display their buffer's selections, search matches, semantic highlighting, and diagnostics. Unknown languages retain the normal foreground; historical snapshots do not borrow live-buffer LSP tokens.
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.
Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched.
In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Switching Before/After synchronizes the primary cursor by aligned diff row and display column. A missing counterpart uses the nearest visible source row; shorter lines clamp the column while retaining the preferred display column for the return trip. Vertical wheel input scrolls the visible panes without moving the focused text caret; switching sides by vertical wheel also synchronizes the cursor. Over Files it navigates visible tree rows without opening them. Horizontal wheel input in either split synchronizes the Before/After horizontal offsets, bounded by the longer side, without changing focus, cursor, or selections; over Files it is consumed. Added/deleted files retain single-pane scrolling. Escape cancels modes, prompts, or filtering, or returns picker focus to the last bound text pane; **it never closes the diff**. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias.
+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);