From 843e4b9852e679ab1a9fa9ab9658971c9cdf0d23 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 14:31:54 +0200 Subject: [PATCH] fix: preserve diff semantic highlighting across pane focus --- docs/editing.md | 2 + src/editor.rs | 21 +++++++ src/tui.rs | 143 +++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 158 insertions(+), 8 deletions(-) diff --git a/docs/editing.md b/docs/editing.md index 34eb978..1bda591 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -255,6 +255,8 @@ Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings li 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 pans only the hovered text pane, with independent Before/After offsets and no change to focus, cursor, or selections; over Files it is consumed. 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. +Both diff panes apply available semantic tokens from their own buffer, independently of keyboard focus. Historical snapshots never borrow the working buffer's tokens; live After tokens must match the displayed text revision. Missing or invalidated tokens leave Tree-sitter syntax colors intact. Semantic colors and modifiers preserve the change backgrounds. + Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file. ## Command palette diff --git a/src/editor.rs b/src/editor.rs index 11619d1..b56259f 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1477,6 +1477,27 @@ impl Editor { .map(|_| focus) } + pub(crate) fn diff_semantic_tokens(&self, focus: Focus) -> &[SemanticToken] { + let Some(diff) = self.diff.as_ref() else { + return &[]; + }; + let buffer = if focus == Focus::After && diff.after_editable() { + diff.live_buffer + .and_then(|id| self.buffer(id)) + .filter(|buffer| Some(buffer.text_revision) == diff.live_revision) + } else { + let Some(comparison) = diff.comparison_id else { + return &[]; + }; + std::iter::once(&self.active) + .chain(self.parked.values()) + .find(|buffer| buffer.diff_snapshot == Some((comparison, diff.file, focus))) + }; + buffer + .and_then(|buffer| buffer.semantic_tokens.as_deref()) + .unwrap_or_default() + } + pub(crate) fn mutation_blocked(&self) -> bool { self.active.diff_snapshot.is_some() || (self.diff.is_some() && !self.diff_editing()) } diff --git a/src/tui.rs b/src/tui.rs index e31af9c..53c23ee 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -613,6 +613,7 @@ fn draw( 1, ); paint_syntax_line(frame, line, syntax.line(row), area, *left); + paint_semantic_line(frame, line, editor.semantic_tokens(), row, area, *left); paint_editor_line(frame, editor, row, area, *left, &carets, &selections); } } @@ -840,19 +841,14 @@ fn paint_grapheme_range( } } -fn paint_editor_line( +fn paint_semantic_line( frame: &mut Frame, - editor: &Editor, + line: &str, + semantic: &[SemanticToken], row: usize, area: Rect, left: usize, - carets: &[Cursor], - selections: &[Selection], ) { - let Some(line) = editor.lines().get(row) else { - return; - }; - let semantic = editor.semantic_tokens(); let mut token = semantic.partition_point(|token| token.start.row < row); let mut token_style = None; let mut column = 0; @@ -884,6 +880,20 @@ fn paint_editor_line( } column = end; } +} + +fn paint_editor_line( + frame: &mut Frame, + editor: &Editor, + row: usize, + area: Rect, + left: usize, + carets: &[Cursor], + selections: &[Selection], +) { + let Some(line) = editor.lines().get(row) else { + return; + }; for severity in [ DiagnosticSeverity::Hint, DiagnosticSeverity::Information, @@ -1439,6 +1449,7 @@ fn draw_diff( before_area, true, &syntax.before, + editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::Before)), ( top, &mut before_left, @@ -1468,6 +1479,7 @@ fn draw_diff( after_area, false, &syntax.after, + editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::After)), ( top, &mut after_left, @@ -1547,6 +1559,7 @@ fn diff_status(status: FileStatus) -> &'static str { } } +#[allow(clippy::too_many_arguments)] fn draw_diff_pane( frame: &mut Frame, diff: &DiffState, @@ -1554,6 +1567,7 @@ fn draw_diff_pane( area: Rect, before: bool, syntax: &SyntaxCache, + semantic: &[SemanticToken], viewport: (usize, &mut usize, Option), ) -> Rect { if area.width == 0 || area.height == 0 { @@ -1699,6 +1713,14 @@ fn draw_diff_pane( Rect::new(content.x + gutter as u16, row_area.y, width as u16, 1), left, ); + paint_semantic_line( + frame, + source_text, + semantic, + index, + Rect::new(content.x + gutter as u16, row_area.y, width as u16, 1), + left, + ); } Rect::new( content.x + gutter as u16, @@ -3925,6 +3947,111 @@ mod tests { } } + #[test] + fn diff_semantic_colors_stay_buffer_local_across_focus_changes() { + for writable in [false, true] { + let directory = Directory::new(); + let path = directory.file("live.rs", "let value = 2;"); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + editor.execute("diff"); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); + editor.handle(Input::Down); + editor.handle(Input::Enter); + editor.handle(Input::Enter); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result( + id, + Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { + root: directory.0.clone(), + editable_paths: if writable { + [std::path::PathBuf::from("live.rs")].into() + } else { + Default::default() + }, + before: "baseline".into(), + after: "Working checkout".into(), + files: vec![syntax_diff_file( + "live.rs", + "let value = 1;", + "let value = 2;", + )], + })), + ); + editor.handle(Input::Char('\t')); + assert_eq!(editor.diff_buffer_focus(), Some(Focus::Before)); + editor + .replace_semantic_tokens( + editor.buffer_id(), + editor.text_revision(), + Some(Arc::from([semantic_token( + 0, + 4, + 9, + SemanticTokenKind::Parameter, + 0, + )])), + ) + .unwrap(); + editor.handle(Input::Char('\t')); + editor + .replace_semantic_tokens( + editor.buffer_id(), + editor.text_revision(), + Some(Arc::from([semantic_token( + 0, + 4, + 9, + SemanticTokenKind::Function, + SemanticTokenModifier::Declaration as u16, + )])), + ) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); + let mut views = BufferViewports::default(); + for _ in 0..4 { + terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); + let regions = views.panels.diff.as_ref().unwrap().regions; + for (index, color, bold) in + [(1, Color::LightCyan, false), (2, Color::LightBlue, true)] + { + let pane = regions[index].0; + let cell = &terminal.backend().buffer()[(pane.x + 8, pane.y + 1)]; + assert_eq!(cell.symbol(), "v"); + assert_eq!(cell.fg, color); + assert_eq!(cell.modifier.contains(ratatui::style::Modifier::BOLD), bold); + assert_eq!( + cell.bg, + if index == 1 { + Color::Rgb(64, 24, 24) + } else { + Color::Rgb(24, 48, 32) + } + ); + } + editor.handle(Input::Char('\t')); + } + editor + .replace_semantic_tokens(editor.buffer_id(), editor.text_revision(), None) + .unwrap(); + terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); + let regions = views.panels.diff.as_ref().unwrap().regions; + let before = regions[1].0; + let after = regions[2].0; + assert_eq!( + terminal.backend().buffer()[(before.x + 8, before.y + 1)].fg, + Color::LightCyan + ); + let value = &terminal.backend().buffer()[(after.x + 8, after.y + 1)]; + assert_eq!(value.fg, Color::Reset); + assert!(!value.modifier.contains(ratatui::style::Modifier::BOLD)); + assert_eq!( + terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg, + Color::Magenta + ); + } + } + #[test] fn editable_after_renders_live_syntax_caret_visual_selection_and_command_prompt() { let directory = Directory::new();