From db5d417a17b7d5b17749a31055539932e1960b23 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 01:08:45 +0200 Subject: [PATCH] feat: add syntax-aware diff backgrounds and diagonal gap shading --- docs/architecture.md | 4 +- docs/development.md | 1 + docs/editing.md | 6 +- src/diff.rs | 23 +- src/editor.rs | 2 +- src/syntax.rs | 5 + src/tui.rs | 565 +++++++++++++++++++++++++++++++++++++------ 7 files changed, 529 insertions(+), 77 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 914f005..399a998 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,7 +20,7 @@ One crate; no workspace or terminal-coordinate types in the editing backend: - `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, definition/hover results, completion decoding, and semantic token requests/legend decoding. Its private configuration resolver supplies presets without restricting language IDs. - `snippet`: owns bounded, frontend-independent template parsing and expansion into plain replacement text and byte-relative tabstops with per-occurrence ancestry. It never edits documents or owns input policy. - `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation/completion, and retires failed plugin ownership. A cancellable diff worker owns repository I/O off the input thread; tagged results cannot reopen closed or superseded selections. Diff focus suppresses asynchronous editing/navigation effects without retiring healthy plugins. -- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy. +- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID or immutable diff snapshot/file identity, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy. - `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module. - The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI. @@ -64,6 +64,8 @@ The buffer viewport includes a fixed left gutter with right-aligned hybrid line Each buffer has a frontend-owned parser/tree cache. Unchanged revision/path skips comparison and parsing; unchanged text at a later revision also avoids parsing. Actual text changes produce a minimal UTF-8-safe byte edit with newline-aware Tree-sitter Points, edit the prior tree, then parse using that tree for incremental reuse. Grammar changes reset the tree, including TypeScript versus TSX despite their shared display name. Native highlight/local/injection queries resolve capture precedence and scoped definitions into nonoverlapping original-line spans; unchanged injected trees are reused. Buffer switching restores its cache, and closing retires it. Incomplete text and grammar ERROR nodes remain highlightable; genuine language/query/parser failures expose a status error and readable plain text. Full-tree highlight queries still run after incremental parsing—a deliberate ceiling to revisit if measured costly. There is no local semantic inference engine or runtime grammar download. +Diff highlighting lazily owns two instances of the same syntax cache, keyed by the accepted comparison request ID and selected file. Before detects language from the old path when renamed; After uses the new path. Scrolling, focus changes, and explorer toggling reuse parsed spans without copying/reparsing snapshots. A fresh comparison identity prevents stale Working-checkout colors even when labels and paths are unchanged. Closing the view releases snapshot caches. Syntax styles patch foreground/modifiers over diff backgrounds; historical panes never receive live-buffer semantic tokens. + ## Clipboard providers macOS uses fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`, preserving literal RTF/EPS text rather than treating it as rich clipboard data. Linux supports Wayland with `wl-paste`/`wl-copy`, or X11 with `xclip`, on PATH with the corresponding display environment. Other platforms report unsupported access. Commands have a two-second deadline and bounded output; failures terminate/reap their process group. diff --git a/docs/development.md b/docs/development.md index c3d1494..9a767b3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -49,6 +49,7 @@ Diff checks cover staged category/filter navigation, stale request cancellation, Changed-file explorer checks cover reuse of the compact directory tree, removed/historical paths, file-to-directory transitions, remapped tree/selection bindings, filter/open/focus transitions, right-dock geometry, and wheel routing without edits to the underlying document. Visibility checks preserve browsing and pane state across Ctrl+e, exclude hidden explorers from focus/wheel routing, and verify full-width review. Status checks preserve catalog associations through sorting, compaction, duplicate inputs, and file/directory collisions, and cover colored A/M/D/R markers, selected/unfocused rows, Tree/List filtering, and sanitized rename paths. +Diff styling checks cover independent snapshot language/shebang/injection detection, accepted/stale snapshot identity, real-parser cache freshness/reuse/release, syntax foreground over changed backgrounds, real empty rows versus missing counterparts, scrolling-stable diagonal hatching, and tab/wide/combining/control-character clipping. TestBackend omits wide-glyph continuation cells; terminal smoke verifies their actual styling. ## Performance and process limits diff --git a/docs/editing.md b/docs/editing.md index 72cda53..c1c59e2 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -215,7 +215,11 @@ The read-only comparison has Before on the left, After on the right, and the cha File rows show colored **A** (added, green), **M** (modified, yellow), **D** (removed, red), or **R** (renamed, cyan) markers in Tree and List views, including filtered results. Rename rows show the new name followed by `← old/path`; directories have no file-status marker. Ordinary file exploration remains unannotated. -Added/deleted rows are aligned with blank counterparts. Tab/Shift+Tab cycles focus between Files, Before, and After. In text panes, Up/Down or Ctrl+j/k scrolls both panes together; Left/Right or Ctrl+h/l scrolls the focused pane horizontally. Home/End goes to the first/last tree row or aligned text row. Wheel input over a text pane scrolls both panes; over Files it navigates visible tree rows without opening them. Esc first leaves filtering when the tree is focused; otherwise it closes the diff and preserves the editor's buffers, cursors, unsaved text, and undo history. Typing and pasted text cannot edit either snapshot. +Changed text rows use muted red backgrounds in Before and muted green backgrounds in After, including blank tails and real empty lines. 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). Unknown languages retain the normal foreground; historical snapshots do not borrow live-buffer LSP tokens. + +Missing counterparts use neutral `/` stripes instead of blank rows. Each successive aligned row shifts the stripes one column left, forming continuous diagonals; the phase follows aligned row and horizontal offset so scrolling does not restart the pattern. Actual empty source lines retain their normal/change background and are not hatched. + +Tab/Shift+Tab cycles focus between Files, Before, and After. In text panes, Up/Down or Ctrl+j/k scrolls both panes together; Left/Right or Ctrl+h/l scrolls the focused pane horizontally. Home/End goes to the first/last tree row or aligned text row. Wheel input over a text pane scrolls both panes; over Files it navigates visible tree rows without opening them. Esc first leaves filtering when the tree is focused; otherwise it closes the diff and preserves the editor's buffers, cursors, unsaved text, and undo history. Typing and pasted text cannot edit either snapshot. 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. diff --git a/src/diff.rs b/src/diff.rs index 25f2225..0653cbb 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -147,6 +147,7 @@ pub struct DiffState { pub loading: bool, pub error: Option, pub comparison: Option, + pub comparison_id: Option, pub files: Option, pub files_visible: bool, pub file: usize, @@ -177,6 +178,7 @@ impl DiffState { loading: true, error: None, comparison: None, + comparison_id: None, files: None, files_visible: true, file: 0, @@ -215,7 +217,7 @@ impl DiffState { self.error = None; } - pub(crate) fn apply_result(&mut self, result: Result) { + pub(crate) fn apply_result(&mut self, id: u64, result: Result) { let expected = self.request_kind.take(); self.loading = false; self.pending = None; @@ -254,6 +256,7 @@ impl DiffState { } self.files = Some(files); self.comparison = Some(comparison); + self.comparison_id = Some(id); self.phase = Phase::View; self.focus = Focus::Files; self.file = 0; @@ -1236,6 +1239,24 @@ mod tests { assert_eq!(editor.diff().unwrap().file, 0); } + #[test] + fn comparison_identity_comes_from_accepted_requests_not_working_labels() { + let mut editor = editor(); + open(&mut editor); + let first = choose_pair(&mut editor); + assert!(editor.apply_diff_result(first, Ok(Outcome::Comparison(comparison())))); + assert_eq!(editor.diff().unwrap().comparison_id, Some(first)); + editor.handle(Input::Escape); + open(&mut editor); + assert_eq!(editor.diff().unwrap().comparison_id, None); + let second = choose_pair(&mut editor); + assert!(second > first); + assert!(!editor.apply_diff_result(first, Ok(Outcome::Comparison(comparison())))); + assert_eq!(editor.diff().unwrap().comparison_id, None); + assert!(editor.apply_diff_result(second, Ok(Outcome::Comparison(comparison())))); + assert_eq!(editor.diff().unwrap().comparison_id, Some(second)); + } + #[test] fn discovery_and_fetch_errors_are_retryable_and_stale_responses_cannot_reopen() { let mut editor = editor(); diff --git a/src/editor.rs b/src/editor.rs index 253e93b..f01fc10 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1259,7 +1259,7 @@ impl Editor { let Some(diff) = &mut self.diff else { return false; }; - diff.apply_result(result); + diff.apply_result(id, result); true } diff --git a/src/syntax.rs b/src/syntax.rs index 6d73293..dbed8f2 100644 --- a/src/syntax.rs +++ b/src/syntax.rs @@ -156,6 +156,11 @@ impl SyntaxCache { self.error.as_deref() } + #[cfg(test)] + pub(crate) fn parse_counts(&self) -> (usize, usize) { + (self.parses, self.injection_parses) + } + fn highlight( &mut self, syntax: &'static LanguageSpec, diff --git a/src/tui.rs b/src/tui.rs index 7cf2472..5cc5bc0 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -41,7 +41,7 @@ use crate::{ Diagnostic, DiagnosticSeverity, SemanticToken, SemanticTokenKind, SemanticTokenModifier, }, session::EditorSession, - syntax::{SyntaxCache, SyntaxKind}, + syntax::{SyntaxCache, SyntaxKind, SyntaxSpan}, }; pub const CAPABILITIES: &[&str] = &["text", "styled_text", "diagnostics"]; @@ -427,6 +427,7 @@ fn draw( ); return; } + panels.diff_syntax = None; syntax.update(editor.lines(), editor.path(), editor.text_revision()); if area.width == 0 || area.height == 0 { return; @@ -925,6 +926,19 @@ fn draw_diff( file_navigation_hint: &str, panels: &mut WheelPanels, ) { + let comparison = diff.comparison.as_ref(); + let selected = comparison.and_then(|comparison| comparison.files.get(diff.file)); + if diff.phase == Phase::View + && let Some(id) = diff.comparison_id + && let Some(file) = selected + { + panels + .diff_syntax + .get_or_insert_with(DiffSyntaxCache::default) + .update(id, diff.file, file); + } else { + panels.diff_syntax = None; + } if area.width == 0 || area.height == 0 { return; } @@ -1113,13 +1127,15 @@ fn draw_diff( return; } - let comparison = diff.comparison.as_ref(); - let selected = comparison.and_then(|comparison| comparison.files.get(diff.file)); + let syntax_error = panels + .diff_syntax + .as_ref() + .and_then(|syntax| syntax.before.error().or_else(|| syntax.after.error())); let notice = diff.error.as_deref().or_else(|| { if diff.loading { Some("Loading comparison…") } else { - selected.and_then(|file| file.notice.as_deref()) + syntax_error.or_else(|| selected.and_then(|file| file.notice.as_deref())) } }); let [header, notice_area, workspace, help] = Layout::vertical([ @@ -1145,7 +1161,7 @@ fn draw_diff( if let Some(notice) = notice { frame.render_widget( Paragraph::new(display_text(notice)).style(Style::default().fg( - if diff.error.is_some() { + if diff.error.is_some() || syntax_error.is_some() { Color::Red } else { Color::Yellow @@ -1173,9 +1189,11 @@ fn draw_diff( comparison.map(|comparison| comparison.files.as_slice()), ); } - if let Some(file) = selected { - draw_diff_pane(frame, diff, file, before_area, true); - draw_diff_pane(frame, diff, file, after_area, false); + if let Some(file) = selected + && let Some(syntax) = &panels.diff_syntax + { + draw_diff_pane(frame, diff, file, before_area, true, &syntax.before); + draw_diff_pane(frame, diff, file, after_area, false, &syntax.after); } else { frame.render_widget( Paragraph::new(if diff.loading { @@ -1217,6 +1235,7 @@ fn draw_diff_pane( file: &ChangedFile, area: Rect, before: bool, + syntax: &SyntaxCache, ) { if area.width == 0 || area.height == 0 { return; @@ -1259,62 +1278,94 @@ fn draw_diff_pane( 0 }; let width = usize::from(content.width).saturating_sub(gutter); - let lines: Vec = diff + for (aligned_row, row) in diff .rows .iter() + .enumerate() .skip(diff.top) .take(usize::from(content.height)) - .map(|row| { - let index = if before { row.before } else { row.after }; - let changed = matches!(row.kind, RowKind::Changed) - || (before && row.kind == RowKind::Removed) - || (!before && row.kind == RowKind::Added); + { + let row_area = Rect::new( + content.x, + content.y + (aligned_row - diff.top) as u16, + content.width, + 1, + ); + let index = if before { row.before } else { row.after }; + let Some(index) = index else { + let style = Style::default() + .fg(Color::Rgb(72, 72, 72)) + .bg(Color::Rgb(28, 28, 28)); + let buffer = frame.buffer_mut(); + for x in 0..content.width { + let slash = (usize::from(x) % 4 + aligned_row % 4 + left % 4) % 4 == 0; + buffer[(content.x + x, row_area.y)] + .set_symbol(if slash { "/" } else { " " }) + .set_style(style); + } + continue; + }; + let Some(source_text) = source.get(index) else { + continue; + }; + let changed = matches!(row.kind, RowKind::Changed) + || (before && row.kind == RowKind::Removed) + || (!before && row.kind == RowKind::Added); + let background = if changed { + if before { + Color::Rgb(64, 24, 24) + } else { + Color::Rgb(24, 48, 32) + } + } else { + Color::Reset + }; + let text = if source_text.chars().any(|ch| ch.is_control() && ch != '\t') { + Cow::Owned( + source_text + .chars() + .map(|ch| { + if ch.is_control() && ch != '\t' { + '�' + } else { + ch + } + }) + .collect::(), + ) + } else { + Cow::Borrowed(source_text.as_str()) + }; + frame.render_widget( + Paragraph::new(visible_line(&text, left, width)) + .style(Style::default().fg(Color::Reset).bg(background)), + Rect::new(content.x + gutter as u16, row_area.y, width as u16, 1), + ); + if gutter > 0 { + let marker = if changed { + if before { '-' } else { '+' } + } else { + ' ' + }; let color = if changed { if before { Color::Red } else { Color::Green } } else { - Color::Reset + Color::DarkGray }; - let Some(index) = index else { - return Line::from(""); - }; - let Some(text) = source.get(index) else { - return Line::from(""); - }; - let text = if text.chars().any(|ch| ch.is_control() && ch != '\t') { - Cow::Owned( - text.chars() - .map(|ch| { - if ch.is_control() && ch != '\t' { - '�' - } else { - ch - } - }) - .collect::(), - ) - } else { - Cow::Borrowed(text.as_str()) - }; - let mut spans = Vec::with_capacity(2); - if gutter > 0 { - let marker = if changed { - if before { '-' } else { '+' } - } else { - ' ' - }; - spans.push(Span::styled( - format!("{:>digits$}{marker} ", index + 1), - Style::default().fg(if changed { color } else { Color::DarkGray }), - )); - } - spans.push(Span::styled( - visible_line(&text, left, width), - Style::default().fg(color), - )); - Line::from(spans) - }) - .collect(); - frame.render_widget(Paragraph::new(lines), content); + frame.render_widget( + Paragraph::new(format!("{:>digits$}{marker} ", index + 1)) + .style(Style::default().fg(color).bg(background)), + Rect::new(content.x, row_area.y, gutter as u16, 1), + ); + } + paint_syntax_line( + frame, + source_text, + syntax.line(index), + Rect::new(content.x + gutter as u16, row_area.y, width as u16, 1), + left, + ); + } } fn floating_panel(area: Rect) -> Rect { @@ -1379,6 +1430,34 @@ fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, ar } } +#[derive(Default)] +struct DiffSyntaxCache { + key: Option<(u64, usize)>, + revision: u64, + before: SyntaxCache, + after: SyntaxCache, +} + +impl DiffSyntaxCache { + fn update(&mut self, comparison_id: u64, file_index: usize, file: &ChangedFile) { + let key = Some((comparison_id, file_index)); + if self.key != key { + self.key = key; + self.revision = self + .revision + .checked_add(1) + .expect("diff syntax revisions exhausted"); + } + self.before.update( + &file.before, + Some(file.old_path.as_deref().unwrap_or(&file.path)), + self.revision, + ); + self.after + .update(&file.after, Some(&file.path), self.revision); + } +} + #[derive(Default)] struct WheelPanels { completion: Option, @@ -1387,6 +1466,7 @@ struct WheelPanels { files: Option<(Rect, usize, usize)>, manual_files: Option, diff: Option, + diff_syntax: Option, } struct FileWheelOffset { @@ -2067,6 +2147,45 @@ fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode, navigation_hin ); } +fn paint_syntax_line( + frame: &mut Frame, + source: &str, + spans: &[SyntaxSpan], + area: Rect, + left: usize, +) { + if spans.is_empty() || area.width == 0 || area.height == 0 { + return; + } + let right = left.saturating_add(usize::from(area.width)); + let mut span_index = 0; + let mut column = 0; + for (byte, grapheme) in source.grapheme_indices(true) { + if column >= right { + break; + } + let cells = if grapheme == "\t" { + TAB_WIDTH - column % TAB_WIDTH + } else { + UnicodeWidthStr::width(grapheme) + grapheme.chars().filter(|ch| ch.is_control()).count() + }; + let end = column + cells; + while span_index < spans.len() && spans[span_index].end_byte <= byte { + span_index += 1; + } + if let Some(span) = spans.get(span_index).filter(|span| span.start_byte <= byte) + && end > left + && (grapheme == "\t" || (column >= left && end <= right)) + { + let style = syntax_style(span.kind); + for cell in column.max(left)..end.min(right) { + frame.buffer_mut()[(area.x + (cell - left) as u16, area.y)].set_style(style); + } + } + column = end; + } +} + fn syntax_style(kind: SyntaxKind) -> Style { Style::default().fg(match kind { SyntaxKind::Comment => Color::DarkGray, @@ -2192,20 +2311,165 @@ mod tests { } } + fn syntax_diff_file(path: &str, before: &str, after: &str) -> ChangedFile { + let before: Vec = before.split('\n').map(str::to_owned).collect(); + let after: Vec = after.split('\n').map(str::to_owned).collect(); + ChangedFile { + path: path.into(), + old_path: None, + status: FileStatus::Modified, + hunks: vec![crate::diff::Hunk { + before_start: 0, + before_len: before.len(), + after_start: 0, + after_len: after.len(), + }], + before, + after, + notice: None, + } + } + + #[test] + fn diff_syntax_refreshes_same_path_working_snapshots_without_live_buffer_styles() { + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); + let mut panels = WheelPanels::default(); + for (source, foreground) in [ + ("fn main() {}", Color::Magenta), + ("// current snapshot", Color::DarkGray), + ("const VALUE: u8 = 3;", Color::Magenta), + ] { + let mut diff = + diff_comparison(vec![syntax_diff_file("working.rs", "fn old() {}", source)]); + diff.files_visible = false; + render_diff(&mut terminal, &diff, &mut panels); + 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)); + } + } + + #[test] + fn diff_syntax_uses_each_historic_path_shebang_and_injected_language() { + let mut file = syntax_diff_file("new.rs", "return 42", "fn main() {}"); + file.old_path = Some("old.py".into()); + let mut syntax = DiffSyntaxCache::default(); + syntax.update(1, 0, &file); + assert_eq!(syntax.before.language(), Some("Python")); + assert_eq!(syntax.after.language(), Some("Rust")); + assert_eq!(syntax.before.line(0)[0].kind, SyntaxKind::Keyword); + assert_eq!(syntax.after.line(0)[0].kind, SyntaxKind::Keyword); + file.path = "unknown.extension".into(); + syntax.update(1, 0, &file); + assert_eq!(syntax.after.language(), None); + assert!(syntax.after.line(0).is_empty()); + + let mut file = syntax_diff_file( + "script", + "```rust\nfn old() {}\n```", + "#!/usr/bin/env python3\nreturn 42", + ); + file.old_path = Some("history.md".into()); + let mut diff = diff_comparison(vec![file]); + diff.files_visible = false; + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + let syntax = panels.diff_syntax.as_ref().unwrap(); + assert_eq!(syntax.before.language(), Some("Markdown")); + assert_eq!(syntax.after.language(), Some("Python")); + 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), + ), + ] { + let cell = &terminal.backend().buffer()[(region.x + 4, region.y + 2)]; + assert_eq!(cell.fg, Color::Magenta); + assert_eq!(cell.bg, background); + } + } + + #[test] + fn diff_syntax_parses_selected_snapshots_not_scroll_focus_or_redraw() { + let first = syntax_diff_file("a.rs", "fn old() {}\n// old", "fn main() {}\n// new"); + let second = syntax_diff_file("b.rs", "// prior file", "// selected file"); + let mut diff = diff_comparison(vec![first, second]); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + let syntax = panels.diff_syntax.as_ref().unwrap(); + let counts = (syntax.before.parse_counts(), syntax.after.parse_counts()); + assert_eq!(counts, ((1, 0), (1, 0))); + for focus in [Focus::Before, Focus::After, Focus::Files] { + diff.focus = focus; + diff.top = 1; + diff.left_before = 1; + diff.left_after = 2; + render_diff(&mut terminal, &diff, &mut panels); + let syntax = panels.diff_syntax.as_ref().unwrap(); + assert_eq!( + (syntax.before.parse_counts(), syntax.after.parse_counts()), + counts + ); + let after = panels.diff.as_ref().unwrap().regions[2].0; + assert_eq!( + terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg, + Color::DarkGray, + ); + } + diff.files + .as_mut() + .unwrap() + .reveal_path(std::path::Path::new("b.rs")); + diff.handle(Input::Enter); + render_diff(&mut terminal, &diff, &mut panels); + let syntax = panels.diff_syntax.as_ref().unwrap(); + assert_eq!(syntax.before.parse_counts(), (2, 0)); + assert_eq!(syntax.after.parse_counts(), (2, 0)); + let after = panels.diff.as_ref().unwrap().regions[2].0; + assert_eq!( + terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg, + Color::DarkGray, + ); + let editor = Editor::open(None, vec![]).unwrap(); + let mut views = BufferViewports { + panels, + ..BufferViewports::default() + }; + terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); + assert!(views.panels.diff_syntax.is_none()); + } + fn diff_comparison(files: Vec) -> DiffState { + static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let id = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let mut diff = DiffState::new(); diff.take_request(); - diff.apply_result(Ok(crate::diff::Outcome::Catalog(diff_catalog()))); + diff.apply_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); diff.handle(Input::Enter); diff.handle(Input::Enter); diff.take_request(); - diff.apply_result(Ok(crate::diff::Outcome::Comparison( - crate::diff::Comparison { + diff.apply_result( + id, + Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { before: "old revision".into(), after: "Working checkout".into(), files, - }, - ))); + })), + ); diff } @@ -2243,7 +2507,7 @@ mod tests { fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage() { let mut diff = DiffState::new(); diff.take_request(); - diff.apply_result(Ok(crate::diff::Outcome::Catalog(diff_catalog()))); + diff.apply_result(1, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap(); let mut panels = WheelPanels::default(); diff.handle(Input::End); @@ -2297,11 +2561,22 @@ mod tests { let buffer = terminal.backend().buffer(); let y = before.y + 2; assert_eq!(buffer[(before.x + 4, y)].symbol(), "g"); - assert_eq!(buffer[(before.x + 4, y)].fg, Color::Red); + assert_eq!(buffer[(before.x + 4, y)].fg, Color::Reset); assert_eq!(buffer[(before.x + 2, y)].symbol(), "-"); - for x in after.x + 1..after.right() - 1 { - assert_eq!(buffer[(x, y)].symbol(), " "); + for x in before.x + 1..before.right() - 1 { + assert_eq!(buffer[(x, y)].bg, Color::Rgb(64, 24, 24)); + assert_eq!(buffer[(x, y + 1)].bg, Color::Reset); } + let gap_symbols: Vec<_> = (after.x + 1..after.right() - 1) + .map(|x| { + let cell = &buffer[(x, y)]; + let column = x - after.x - 1; + assert_eq!(cell.symbol(), [" ", " ", " ", "/"][usize::from(column % 4)]); + assert_eq!(cell.bg, Color::Rgb(28, 28, 28)); + assert_eq!(cell.fg, Color::Rgb(72, 72, 72)); + cell.symbol().to_owned() + }) + .collect(); assert_eq!(buffer[(before.x + 1, y + 1)].symbol(), "3"); assert_eq!(buffer[(after.x + 1, y + 1)].symbol(), "2"); assert_eq!(buffer[(before.x + 4, y + 1)].symbol(), "t"); @@ -2311,20 +2586,22 @@ mod tests { render_diff(&mut terminal, &diff, &mut panels); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), "g"); - assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), " "); + for (x, symbol) in (after.x + 1..after.right() - 1).zip(gap_symbols) { + assert_eq!(buffer[(x, after.y + 1)].symbol(), symbol); + } assert_eq!(buffer[(before.x, before.y)].fg, Color::Cyan); assert_eq!(buffer[(after.x, after.y)].fg, Color::DarkGray); assert!(screen_text(&terminal).contains("Before focus")); } #[test] - fn diff_added_rows_leave_before_blank_and_show_green_after_numbers() { - let diff = diff_comparison(vec![ChangedFile { + fn diff_added_rows_hatch_missing_before_but_tint_real_empty_after_lines() { + let mut diff = diff_comparison(vec![ChangedFile { path: "added.txt".into(), old_path: None, status: FileStatus::Added, before: vec![], - after: vec!["first addition".into(), "second addition".into()], + after: vec!["first addition".into(), String::new()], hunks: vec![crate::diff::Hunk { before_start: 0, before_len: 0, @@ -2343,12 +2620,146 @@ mod tests { for offset in 0..2 { let y = before.y + 1 + offset; for x in before.x + 1..before.right() - 1 { - assert_eq!(buffer[(x, y)].symbol(), " "); + let column = x - before.x - 1; + let cell = &buffer[(x, y)]; + let pattern = if offset == 0 { + ["/", " ", " ", " "] + } else { + [" ", " ", " ", "/"] + }; + assert_eq!(cell.symbol(), pattern[usize::from(column % 4)]); + assert_eq!(cell.bg, Color::Rgb(28, 28, 28)); + assert_eq!(cell.fg, Color::Rgb(72, 72, 72)); } assert_eq!(buffer[(after.x + 1, y)].symbol(), (offset + 1).to_string()); assert_eq!(buffer[(after.x + 2, y)].symbol(), "+"); - assert_eq!(buffer[(after.x + 4, y)].fg, Color::Green); + assert_eq!(buffer[(after.x + 2, y)].fg, Color::Green); + assert_eq!(buffer[(after.x + 4, y)].fg, Color::Reset); + for x in after.x + 1..after.right() - 1 { + assert_eq!(buffer[(x, y)].bg, Color::Rgb(24, 48, 32)); + if offset == 1 && x >= after.x + 4 { + assert_eq!(buffer[(x, y)].symbol(), " "); + } + } } + diff.top = 1; + diff.left_before = 1; + render_diff(&mut terminal, &diff, &mut panels); + let buffer = terminal.backend().buffer(); + for x in before.x + 1..before.right() - 1 { + let column = x - before.x - 1; + assert_eq!( + buffer[(x, before.y + 1)].symbol(), + [" ", " ", "/", " "][usize::from(column % 4)], + ); + } + assert_eq!(buffer[(before.x, before.y + 1)].symbol(), "│"); + assert_eq!(buffer[(before.right() - 1, before.y + 1)].symbol(), "│"); + } + + #[test] + fn diff_changed_backgrounds_preserve_syntax_and_clipped_source_cells() { + let mut diff = diff_comparison(vec![ChangedFile { + path: "changed.rs".into(), + old_path: None, + status: FileStatus::Modified, + before: [ + "fn old() {", + "\tlet word = \"界e\u{301}old\";", + "\tlet count = 42; //\u{1b}note", + "", + "}", + ] + .map(String::from) + .to_vec(), + after: [ + "fn new() {", + "\tlet word = \"界e\u{301}new\";", + "\tlet count = 24; //\u{1b}note", + "", + "}", + ] + .map(String::from) + .to_vec(), + hunks: vec![crate::diff::Hunk { + before_start: 0, + before_len: 5, + after_start: 0, + after_len: 5, + }], + notice: None, + }]); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 12)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + let geometry = panels.diff.as_ref().unwrap(); + 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 (column, row, foreground) in [ + (0, 0, Color::Magenta), + (3, 0, Color::LightBlue), + (4, 1, Color::Magenta), + (15, 1, Color::Green), + (16, 1, Color::Green), + (18, 1, Color::Green), + (16, 2, Color::Yellow), + (22, 2, Color::DarkGray), + (23, 2, Color::DarkGray), + ] { + assert_eq!( + buffer[(pane.x + 4 + column, pane.y + 1 + row)].fg, + foreground + ); + } + assert_eq!(buffer[(pane.x + 20, pane.y + 2)].symbol(), "界"); + assert_eq!(buffer[(pane.x + 22, pane.y + 2)].symbol(), "e\u{301}"); + assert_eq!(buffer[(pane.x + 26, pane.y + 3)].symbol(), "�"); + assert_eq!(buffer[(pane.x + 27, pane.y + 3)].symbol(), "n"); + for x in pane.x + 4..pane.right() - 1 { + assert_eq!(buffer[(x, pane.y + 4)].symbol(), " "); + assert_eq!(buffer[(x, pane.y + 4)].fg, Color::Reset); + } + } + diff.top = 1; + diff.left_before = 17; + diff.left_after = 16; + render_diff(&mut terminal, &diff, &mut panels); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), " "); + assert_eq!(buffer[(before.x + 4, before.y + 1)].fg, Color::Reset); + assert_eq!( + buffer[(before.x + 4, before.y + 1)].bg, + Color::Rgb(64, 24, 24) + ); + assert_eq!(buffer[(before.x + 5, before.y + 1)].symbol(), "e\u{301}"); + assert_eq!(buffer[(before.x + 5, before.y + 1)].fg, Color::Green); + assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), "界"); + assert_eq!(buffer[(after.x + 4, after.y + 1)].fg, Color::Green); + assert_eq!( + buffer[(after.x + 4, after.y + 1)].bg, + Color::Rgb(24, 48, 32) + ); + assert_eq!(buffer[(after.x + 6, after.y + 1)].symbol(), "e\u{301}"); + assert_eq!(buffer[(after.x + 6, after.y + 1)].fg, Color::Green); } #[test] @@ -2387,7 +2798,15 @@ mod tests { assert_eq!(buffer[(before.x + 6, before.y + 1)].symbol(), "e\u{301}"); assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), " "); assert_eq!(buffer[(after.x + 8, after.y + 1)].symbol(), "界"); - assert_eq!(buffer[(after.x + 8, after.y + 1)].fg, Color::Green); + assert_eq!(buffer[(after.x + 8, after.y + 1)].fg, Color::Reset); + assert_eq!( + buffer[(after.x + 8, after.y + 1)].bg, + Color::Rgb(24, 48, 32) + ); + assert_eq!( + buffer[(before.x + 4, before.y + 1)].bg, + Color::Rgb(64, 24, 24) + ); diff.handle(Input::Char('\t')); render_diff(&mut terminal, &diff, &mut panels); assert!(screen_text(&terminal).contains("After focus")); @@ -2597,7 +3016,7 @@ mod tests { render_diff(&mut terminal, &diff, &mut panels); assert!(screen_text(&terminal).contains("Loading repository")); diff.take_request(); - diff.apply_result(Err("permission denied".into())); + diff.apply_result(1, Err("permission denied".into())); render_diff(&mut terminal, &diff, &mut panels); assert!(screen_text(&terminal).contains("permission denied")); let empty = diff_comparison(vec![]);