diff --git a/TODO.md b/TODO.md index 0104783..93c9f00 100644 --- a/TODO.md +++ b/TODO.md @@ -5,9 +5,11 @@ - [x] add support for zig syntax highlighting - [x] add the `*` action from vim on word to highlights and searches in buffer in one action. - [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 +- [x] diff view: show the function/class context that a change hunk belongs to. + - inline scope labels with sticky continuation, chosen before implementation + - each side uses its own syntax tree; scope changes within a hunk get separate labels +- [ ] diff view: automatically detect changes and update the view when relevant bookmarks move change ids (regards bookmark-based diff view; i.e., user selected at least one bookmark in the before/after picker) +- [ ] diff view: annotate folder with checkmark if all items under given folder has been checked AND the folder is collapsed. - [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc. - [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding. - design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files. diff --git a/docs/architecture.md b/docs/architecture.md index bf9354d..780aa4b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -104,6 +104,8 @@ Each buffer has a frontend-owned parser/tree cache. Unchanged revision/path skip 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. +Scope context is a lazily built row index over the existing primary/injected syntax trees, invalidated by source or grammar changes and warmed by diff updates. Ordinary buffer highlighting does not build it. The frontend's visual-row projection inserts inline/sticky scope labels in wrapped and unwrapped diffs; source alignment and excerpt ranges contain no label rows. Coloring, native selection/caret overlays, and source navigation skip these presentation rows. Before/After labels use their independent trees rather than live-workspace symbol analysis. + ## Clipboard providers macOS publishes `public.utf8-plain-text` through the native Pasteboard Manager, checking allocation and OS-status failures before reporting success. Publication is synchronous and avoids per-copy interpreter/process startup; native calls do not have a subprocess deadline. Reads retain fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`. Both paths preserve 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. Subprocess-backed access has a two-second deadline and bounded output; failures terminate/reap the process group. diff --git a/docs/editing.md b/docs/editing.md index 514ced4..a2217a9 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -438,6 +438,8 @@ Review marks belong only to the open comparison and survive filtering, file navi Text panes show change excerpts with **10 unchanged lines above and below** each change, preserving original file line numbers. Overlapping or touching excerpts merge; omitted stretches show an unchanged-line count in both panes. Files with only metadata changes show their notice or “No text changes” rather than unrelated source. +**Scope labels** identify named enclosing functions/classes and containers, such as `impl Review › run`. Muted, unnumbered rows appear at excerpt starts and scope changes; the current scope stays at the top when its declaration scrolls out of view. Each split side uses its own source, so renamed functions keep distinct old/new names. Unified labels use `−` for Before, `+` for After, and `±` for shared names. Narrow panes prefer the innermost name. Labels are presentation only: motions, selections, copying, editing, and line jumps retain original source coordinates. Context comes from the existing Tree-sitter trees, including supported injected code, without requiring an LSP; unsupported syntax gets no guessed label. + In **Normal mode** with a text pane focused, **`[` expands above** and **`]` expands below** the excerpt containing the caret, revealing up to **15 lines** per keypress in either pane. In editable After's Insert mode these keys insert literal text. Expansion clamps at file boundaries and merges excerpts when they meet. Existing visible source stays anchored. Opening a file resets its excerpts to the configured initial context. Live edits recompute changes while retaining expanded context; the caret stays visible even when the last text change is reverted. Live typing highlights only the displayed source rows while retaining each snapshot's original syntax tree for multiline and injected-language context. Line-width caches and unchanged hunk layouts avoid repeated full-file work; edits that change line alignment or revert changes recompute the diff. Already-queued printable Insert keys share a redraw, but every key still passes through normal bindings, snippet handling, and undo history. Mode changes and other terminal events retain their order. diff --git a/src/syntax.rs b/src/syntax.rs index fbb1d24..5d4d8cd 100644 --- a/src/syntax.rs +++ b/src/syntax.rs @@ -1,3 +1,4 @@ +use std::cell::OnceCell; use std::cmp::Reverse; use std::collections::{BTreeSet, HashMap, VecDeque}; use std::path::{Path, PathBuf}; @@ -40,6 +41,25 @@ pub struct SyntaxSpan { pub kind: SyntaxKind, } +/// Innermost named declaration, with its full enclosing path and end-exclusive source rows. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ScopeContext<'a> { + pub start_row: usize, + pub end_row: usize, + pub label: &'a str, +} + +struct CachedScope { + start_row: usize, + end_row: usize, + label: String, +} + +struct ScopeIndex { + declarations: Vec, + rows: Vec>, +} + /// Frontend-owned, per-buffer parser state. Revision and path identify unchanged input. #[derive(Default)] pub struct SyntaxCache { @@ -58,6 +78,7 @@ pub struct SyntaxCache { tree: Option, injection_parser: Parser, injections: Vec, + scopes: OnceCell, cursor: QueryCursor, captures: Vec, highlights: Vec, @@ -131,6 +152,7 @@ impl SyntaxCache { visible.unwrap_or(std::slice::from_ref(&all_rows)), final_newline && !lines.is_empty(), ); + let _ = self.scope_at(0); } fn update_rows( @@ -196,6 +218,7 @@ impl SyntaxCache { } self.highlighted.fill(false); self.context = None; + self.scopes.take(); } if language_changed { self.tree = None; @@ -251,6 +274,7 @@ impl SyntaxCache { self.tree = None; self.injections.clear(); self.context = None; + self.scopes.take(); self.highlighted.fill(false); for spans in self.lines.iter_mut().chain(&mut self.string_spans) { spans.clear(); @@ -268,6 +292,52 @@ impl SyntaxCache { self.lines.get(row).map_or(&[], Vec::as_slice) } + /// Lazily index existing trees; subsequent lookups neither allocate nor reparse. + pub(crate) fn scope_at(&self, row: usize) -> Option> { + let syntax = self.syntax?; + let tree = self.tree.as_ref()?; + if row >= self.lines.len() + || (!has_scope_language(syntax.id) + && !self + .injections + .iter() + .any(|layer| has_scope_language(layer.syntax.id))) + { + return None; + } + let index = self.scopes.get_or_init(|| { + let mut index = ScopeIndex { + declarations: Vec::new(), + rows: vec![None; self.lines.len()], + }; + collect_scopes( + tree.root_node(), + syntax.id, + &self.source, + &[], + &mut index.declarations, + &mut index.rows, + ); + for layer in &self.injections { + collect_scopes( + layer.tree.root_node(), + layer.syntax.id, + &self.source, + &layer.ranges, + &mut index.declarations, + &mut index.rows, + ); + } + index + }); + let scope = &index.declarations[index.rows[row]?]; + Some(ScopeContext { + start_row: scope.start_row, + end_row: scope.end_row, + label: &scope.label, + }) + } + /// String coverage before foreground overrides, including embedded-language highlighting. /// An included line break has end_byte == line.len() + 1; foreground spans exclude it. pub(crate) fn string_spans(&self, row: usize) -> &[SyntaxSpan] { @@ -473,6 +543,244 @@ impl SyntaxCache { } } +fn has_scope_language(language: &str) -> bool { + matches!( + language, + "rust" + | "python" + | "ruby" + | "javascript" + | "typescript" + | "typescriptreact" + | "go" + | "c" + | "cpp" + | "shellscript" + | "zig" + ) +} + +fn collect_scopes( + root: Node<'_>, + language: &str, + source: &str, + ranges: &[Range], + scopes: &mut Vec, + rows: &mut [Option], +) { + if !has_scope_language(language) { + return; + } + let mut cursor = root.walk(); + let mut ancestors: Vec<(usize, usize)> = Vec::new(); + loop { + let node = cursor.node(); + while ancestors + .last() + .is_some_and(|(_, end)| *end <= node.start_byte()) + { + ancestors.pop(); + } + if let Some((extent, name)) = + scope_declaration(node, language, source).filter(|(extent, _)| { + ranges.is_empty() + || ranges.iter().any(|range| { + range.start_byte <= extent.start_byte() + && extent.end_byte() <= range.end_byte + }) + }) + { + let parent = ancestors + .last() + .map_or("", |(index, _)| scopes[*index].label.as_str()); + let label = if parent.is_empty() { + name + } else { + format!("{parent} › {name}") + }; + let start_row = extent.start_position().row; + let end = extent.end_position(); + let end_row = end.row + usize::from(end.column > 0); + let index = scopes.len(); + scopes.push(CachedScope { + start_row, + end_row, + label, + }); + let end_row = end_row.min(rows.len()); + rows[start_row.min(end_row)..end_row].fill(Some(index)); + ancestors.push((index, node.end_byte())); + } + if cursor.goto_first_child() { + continue; + } + while !cursor.goto_next_sibling() { + if !cursor.goto_parent() { + return; + } + } + } +} + +fn scope_text<'a>(node: Node<'_>, source: &'a str) -> Option<&'a str> { + (!node.has_error() && !node.is_missing() && node.kind() != "computed_property_name") + .then(|| source.get(node.byte_range())) + .flatten() + .filter(|name| !name.trim().is_empty()) +} + +fn scope_declaration<'a>( + node: Node<'a>, + language: &str, + source: &str, +) -> Option<(Node<'a>, String)> { + if !node.is_named() || node.is_missing() { + return None; + } + let field = |name| node.child_by_field_name(name); + let mut extent = node; + let name = match (language, node.kind()) { + ("rust", "impl_item") => { + let target = scope_text(field("type")?, source)?; + let label = match field("trait").and_then(|node| scope_text(node, source)) { + Some(trait_name) => scope_label(&["impl", trait_name, "for", target]), + None => scope_label(&["impl", target]), + }; + return Some((node, label)); + } + ( + "rust", + "function_item" + | "function_signature_item" + | "trait_item" + | "mod_item" + | "struct_item" + | "enum_item", + ) + | ("python", "function_definition" | "class_definition") + | ("ruby", "class" | "module" | "method" | "singleton_method") + | ( + "javascript" | "typescript" | "typescriptreact", + "function_declaration" + | "generator_function_declaration" + | "method_definition" + | "class_declaration" + | "class" + | "abstract_class_declaration" + | "interface_declaration" + | "internal_module", + ) + | ("go", "function_declaration") + | ( + "c" | "cpp", + "class_specifier" + | "struct_specifier" + | "union_specifier" + | "enum_specifier" + | "namespace_definition", + ) + | ("shellscript", "function_definition") + | ("zig", "function_declaration") => field("name")?, + ("go", "method_declaration") => { + let receiver = field("receiver")? + .named_child(0)? + .child_by_field_name("type")?; + let receiver = scope_text(receiver, source)?; + let name = scope_text(field("name")?, source)?; + return Some((node, scope_label(&[receiver, "›", name]))); + } + ("go", "type_spec") + if field("type") + .is_some_and(|node| matches!(node.kind(), "struct_type" | "interface_type")) => + { + field("name")? + } + ("c" | "cpp", "function_definition") => { + let mut declarator = field("declarator")?; + loop { + if matches!( + declarator.kind(), + "identifier" + | "field_identifier" + | "qualified_identifier" + | "operator_name" + | "destructor_name" + ) { + break declarator; + } + declarator = declarator.child_by_field_name("declarator").or_else(|| { + matches!( + declarator.kind(), + "reference_declarator" | "parenthesized_declarator" + ) + .then(|| declarator.named_child(0)) + .flatten() + })?; + } + } + ( + "javascript" | "typescript" | "typescriptreact", + "arrow_function" | "function_expression" | "generator_function", + ) => { + if let Some(name) = field("name") { + name + } else { + extent = node.parent()?; + match extent.kind() { + "variable_declarator" => { + let name = extent.child_by_field_name("name")?; + (name.kind() == "identifier").then_some(name)? + } + "pair" => extent.child_by_field_name("key")?, + "field_definition" => extent.child_by_field_name("property")?, + "public_field_definition" => extent.child_by_field_name("name")?, + "assignment_expression" => { + let name = extent.child_by_field_name("left")?; + matches!(name.kind(), "identifier" | "member_expression").then_some(name)? + } + _ => return None, + } + } + } + ( + "zig", + "struct_declaration" | "union_declaration" | "enum_declaration" | "opaque_declaration", + ) => { + extent = node.parent()?; + if extent.kind() != "variable_declaration" + || node.prev_sibling().is_none_or(|node| node.kind() != "=") + { + return None; + } + let mut cursor = extent.walk(); + extent + .named_children(&mut cursor) + .find(|node| node.kind() == "identifier")? + } + _ => return None, + }; + if let Some(parent) = node.parent() + && parent.kind() == "decorated_definition" + { + extent = parent; + } + Some((extent, scope_label(&[scope_text(name, source)?]))) +} + +fn scope_label(parts: &[&str]) -> String { + let mut label = String::new(); + for word in parts.iter().flat_map(|part| part.split_whitespace()) { + if !label.is_empty() { + label.push(' '); + } + label.extend( + word.chars() + .map(|ch| if ch.is_control() { '�' } else { ch }), + ); + } + label +} + fn same_language(left: Option<&LanguageSpec>, right: Option<&LanguageSpec>) -> bool { match (left, right) { (Some(left), Some(right)) => std::ptr::eq(left, right), @@ -1249,6 +1557,161 @@ mod tests { source.split('\n').map(str::to_owned).collect() } + #[test] + fn scopes_follow_real_declarations_and_each_sources_own_tree() { + let original = "impl State {\n fn old() {\n call();\n }\n}\n\nfn next() {\n call();\n}\n"; + let before = cache(original, "old.rs"); + let mut live = cache(&original.replace("old()", "renamed()"), "new.rs"); + assert_eq!( + before.scope_at(2), + Some(ScopeContext { + start_row: 1, + end_row: 4, + label: "impl State › old", + }) + ); + assert_eq!(before.scope_at(3).unwrap().label, "impl State › old"); + assert_eq!(before.scope_at(4).unwrap().label, "impl State"); + assert_eq!(before.scope_at(5), None); + assert_eq!( + before.scope_at(6), + Some(ScopeContext { + start_row: 6, + end_row: 9, + label: "next", + }) + ); + assert_eq!(before.scope_at(9), None); + assert_eq!(before.scope_at(usize::MAX), None); + let changed = lines(&original.replace("old()", "updated()")); + live.update_visible(&changed, Some(Path::new("new.rs")), 1, &[2..3]); + assert_eq!(live.scope_at(2).unwrap().label, "impl State › updated"); + assert_eq!(before.scope_at(2).unwrap().label, "impl State › old"); + let counts = live.parse_counts(); + live.update_visible(&changed, Some(Path::new("new.rs")), 2, &[6..9]); + assert_eq!(live.parse_counts(), counts); + live.update_visible(&changed, Some(Path::new("renamed.txt")), 2, &[0..1]); + assert_eq!(live.scope_at(2), None); + assert_eq!(before.scope_at(2).unwrap().label, "impl State › old"); + let incomplete = cache("impl State {\n fn run() {\n let = ;\n }\n}", "a.rs"); + assert_eq!(incomplete.scope_at(2).unwrap().label, "impl State › run"); + + let python = cache( + "class State:\n @decorator\n def run(self):\n work()\n\n def next(self):\n work()\n", + "a.py", + ); + assert_eq!( + python.scope_at(1), + Some(ScopeContext { + start_row: 1, + end_row: 4, + label: "State › run", + }) + ); + assert_eq!(python.scope_at(4).unwrap().label, "State"); + assert_eq!(python.scope_at(5).unwrap().label, "State › next"); + assert_eq!(python.scope_at(7), None); + + for (path, source, expected) in [ + ( + "a.rs", + "trait State {\n fn run(&self) {\n work();\n }\n}", + Some("State › run"), + ), + ( + "a.rb", + "class State\n def run\n work\n end\nend", + Some("State › run"), + ), + ( + "a.js", + "function outer() {\n function inner() {\n work();\n }\n}", + Some("outer › inner"), + ), + ( + "a.ts", + "class State {\n run = () => {\n work();\n };\n}", + Some("State › run"), + ), + ( + "a.tsx", + "const View = () => {\n work();\n return
;\n};", + Some("View"), + ), + ( + "a.js", + "const handlers = { run: () => {\n work();\n} };", + Some("run"), + ), + ( + "a.js", + "State.run = () => {\n work();\n};", + Some("State.run"), + ), + ( + "a.go", + "package main\nfunc (s *State) run() {\n work()\n}", + Some("*State › run"), + ), + ( + "a.go", + "package main\nfunc run() {\n work()\n}", + Some("run"), + ), + ( + "a.c", + "int run(void) {\n work();\n return 7;\n}", + Some("run"), + ), + ( + "a.cpp", + "class State {\n void run() {\n work();\n }\n};", + Some("State › run"), + ), + ( + "a.cpp", + "void State::run() {\n work();\n}", + Some("State::run"), + ), + ("a.sh", "run() {\n work\n}", Some("run")), + ( + "a.zig", + "const State = struct {\n pub fn run() void {\n work();\n }\n};", + Some("State › run"), + ), + ("a.md", "```rust\nfn run() {\n work();\n}\n```", Some("run")), + ( + "a.html", + "", + Some("run"), + ), + ("a.js", "use(() => {\n work();\n});", None), + ( + "a.js", + "const handlers = { [key]: () => {\n work();\n} };", + None, + ), + ("a.txt", "fn run() {\n work();\n}", None), + ("a.unknown", "fn run() {\n work();\n}", None), + ("a.json", "{\"work\": \"function run() {}\"}", None), + ("a.yaml", "work: function run() {}", None), + ] { + let syntax = cache(source, path); + let row = source[..source.find("work").unwrap()] + .bytes() + .filter(|byte| *byte == b'\n') + .count(); + assert_eq!( + syntax.scope_at(row).map(|scope| scope.label), + expected, + "{path}: {source}" + ); + if let Some(scope) = syntax.scope_at(row) { + assert!(scope.start_row <= row && row < scope.end_row, "{path}"); + } + } + } + fn source_range(source: &str) -> Range { Range { start_byte: 0, diff --git a/src/tui.rs b/src/tui.rs index 9e24f2e..3f2bbcb 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -512,6 +512,8 @@ impl HistoryDiff { position: self.top, segments, final_segments, + scope: [false; 2], + unwrapped: false, }); } let previous = self.top; @@ -3635,6 +3637,27 @@ fn draw_diff( PaneLayout::After => [Rect::default(), panes], } }; + // Parse complete trees before projecting context; color only visible source rows below. + if let Some(id) = diff.comparison_id + && let Some(file) = selected.filter(|file| file.loaded) + { + panels + .diff_syntax + .get_or_insert_with(DiffSyntaxCache::default) + .update( + id, + diff.file, + file, + diff.after_source_lines(file), + diff.live_revision, + Some(DiffSyntaxViewport { + diff, + top: diff.top, + areas: [Rect::default(); 2], + wrapped: None, + }), + ); + } let mut document_caret = None; let mut top = diff.top; let mut before_left = diff.left_before; @@ -3652,26 +3675,8 @@ fn draw_diff( }) }) .and_then(|previous| { - let editor = editor?; - let focus = editor.diff_buffer_focus()?; - if previous.view_mode != diff.view_mode || previous.split_axis != diff.split_axis { - return previous.caret_row; - } - let cursor = editor.cursor(); - if previous.wrapping { - let file = selected?; - let viewport = panels.diff_wrap.as_mut()?; - let position = viewport.caret_position(diff, file, focus, cursor)?; - viewport - .rows - .iter() - .position(|row| row.position == position) - } else { - let display = diff.source_display_row(focus, cursor.row)?; - let row = display.checked_sub(previous.viewport?.2)?; - let side = usize::from(focus == Focus::After) + 1; - (row < usize::from(diff_inner(previous.regions[side].0).height)).then_some(row) - } + editor?.diff_buffer_focus()?; + previous.caret_row }); let center_row = editor.and_then(|editor| { let focus = editor.diff_buffer_focus()?; @@ -3693,7 +3698,9 @@ fn draw_diff( .then_some(height / 2) }); let toggle_row = center_row.or(toggle_row); - if wrapping && let Some(file) = selected.filter(|file| file.loaded) { + if let Some(file) = selected.filter(|file| file.loaded) + && let Some(syntax) = &panels.diff_syntax + { let caret = editor .filter(|editor| editor.diff_interacting() && diff.follow_caret) .and_then(|editor| { @@ -3711,6 +3718,7 @@ fn draw_diff( caret, toggle_row, editor.map_or(Some(0), Editor::scroll_distance), + syntax, ); top = viewport.top.display; } else { @@ -3725,15 +3733,6 @@ fn draw_diff( Focus::After => (after_area, &mut after_left), Focus::Files => unreachable!("native diff buffers belong to a text pane"), }; - let height = usize::from(diff_inner(pane).height); - if let Some(row) = diff.source_display_row(focus, editor.cursor().row) { - if let Some(anchor) = toggle_row { - top = row.saturating_sub(anchor.min(height.saturating_sub(1))); - } - if toggle_row.is_none() { - top = scroll_offset(top, row, height, editor.scroll_distance()); - } - } let source_len = selected.map_or(0, |file| match focus { Focus::Before => file.before.len(), Focus::After => file.after.len(), @@ -4063,10 +4062,65 @@ fn draw_diff_pane( let (top, left, previous_width) = viewport; let gutter = diff_gutter(diff, file, usize::from(content.width), focus); let width = usize::from(content.width).saturating_sub(gutter); - if wrapped.is_none() && previous_width.is_some_and(|previous| usize::from(previous) != width) { + if !diff.word_wrap + && !content_only + && previous_width.is_some_and(|previous| usize::from(previous) != width) + { *left = diff.bound_horizontal(focus, *left, width); } let left = *left; + if let Some(rows) = wrapped { + for (screen, row) in rows.iter().take(usize::from(content.height)).enumerate() { + if row.scope == [false; 2] { + continue; + } + let side = if unified { + usize::from(!row.scope[0]) + } else { + usize::from(!before) + }; + if !row.scope[side] { + continue; + } + let source_focus = if side == 0 { + Focus::Before + } else { + Focus::After + }; + let Some(scope) = diff + .display_source_row(source_focus, row.position.display) + .and_then(|source| syntax[side].scope_at(source)) + else { + continue; + }; + let prefix = if width < 4 { + "" + } else if !unified { + "› " + } else if row.scope == [true; 2] { + "± " + } else if side == 0 { + "− " + } else { + "+ " + }; + let prefix_width = UnicodeWidthStr::width(prefix); + let available = width.saturating_sub(prefix_width); + let label = if UnicodeWidthStr::width(scope.label) > available { + scope.label.rsplit(" › ").next().unwrap_or(scope.label) + } else { + scope.label + }; + let style = Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC); + let x = content.x + gutter as u16; + let y = content.y + screen as u16; + let buffer = frame.buffer_mut(); + buffer.set_stringn(x, y, prefix, width, style); + buffer.set_stringn(x + prefix_width as u16, y, label, available, style); + } + } for (screen, display_row, segments) in diff_screen_rows(diff, top, content.height, wrapped) { let row = &diff.display_rows[display_row]; let row_area = Rect::new(content.x, content.y + screen as u16, content.width, 1); @@ -4324,6 +4378,8 @@ struct WrappedDiffRow { position: DiffWrapPosition, segments: [Option; 2], final_segments: [bool; 2], + scope: [bool; 2], + unwrapped: bool, } #[derive(Default)] @@ -4390,6 +4446,15 @@ impl DiffWrapViewport { ) -> WrappedDiffRow { let mut segments = [None; 2]; let mut final_segments = [false; 2]; + if !diff.word_wrap { + return WrappedDiffRow { + position, + segments, + final_segments, + scope: [false; 2], + unwrapped: true, + }; + } if matches!( diff.display_rows.get(position.display), Some(DisplayRow::Line(_)) @@ -4413,10 +4478,15 @@ impl DiffWrapViewport { position, segments, final_segments, + scope: [false; 2], + unwrapped: false, } } fn height(&mut self, diff: &DiffState, file: &ChangedFile, display: usize) -> usize { + if !diff.word_wrap { + return 1; + } if !matches!(diff.display_rows.get(display), Some(DisplayRow::Line(_))) { return 1; } @@ -4462,13 +4532,30 @@ impl DiffWrapViewport { position } - fn fill(&mut self, diff: &DiffState, file: &ChangedFile, height: usize) { + fn fill( + &mut self, + diff: &DiffState, + file: &ChangedFile, + height: usize, + syntax: &DiffSyntaxCache, + ) { self.rows.clear(); if diff.display_rows.is_empty() { return; } let mut position = self.top; - for _ in 0..height { + while self.rows.len() < height { + for scope in syntax.labels(diff, position, self.rows.is_empty()) { + if scope.iter().any(|side| *side) && self.rows.len() + 1 < height { + self.rows.push(WrappedDiffRow { + position, + segments: [None; 2], + final_segments: [false; 2], + scope, + unwrapped: false, + }); + } + } let row = self.row(diff, file, position); self.rows.push(row); let next = self.step(diff, file, position, true); @@ -4494,7 +4581,11 @@ impl DiffWrapViewport { }; let text = source.get(cursor.row).map_or("", String::as_str); let column = display_column(text, cursor.column); - let continuation = caret_segment(cache.segments(cursor.row, text), column); + let continuation = if diff.word_wrap { + caret_segment(cache.segments(cursor.row, text), column) + } else { + 0 + }; Some(DiffWrapPosition { display, continuation, @@ -4509,6 +4600,7 @@ impl DiffWrapViewport { caret: Option<(Focus, Cursor)>, anchor_row: Option, distance: Option, + syntax: &DiffSyntaxCache, ) { let Some(id) = diff.comparison_id else { return }; let key = Some((id, diff.file, diff.live_revision)); @@ -4548,7 +4640,7 @@ impl DiffWrapViewport { let height = usize::from(diff_inner(areas[active]).height); let paint_height = usize::from(diff_inner(areas[0]).height.max(diff_inner(areas[1]).height)); - self.fill(diff, file, paint_height); + self.fill(diff, file, paint_height, syntax); if let Some((focus, cursor)) = caret && let Some(position) = self.caret_position(diff, file, focus, cursor) { @@ -4557,7 +4649,7 @@ impl DiffWrapViewport { .rows .iter() .take(height) - .position(|row| row.position == position); + .position(|row| row.scope == [false; 2] && row.position == position); let anchor = anchor_row.or_else(|| { if height == 0 { None @@ -4574,10 +4666,28 @@ impl DiffWrapViewport { }); if let Some(row) = anchor { self.top = position; - for _ in 0..row.min(height.saturating_sub(1)) { - self.top = self.step(diff, file, self.top, false); + let target = row.min(height.saturating_sub(1)); + let label_count = |position, sticky| { + syntax + .labels(diff, position, sticky) + .iter() + .filter(|scope| scope.iter().any(|side| *side)) + .count() + .min(height.saturating_sub(1)) + }; + let mut screen = label_count(self.top, true); + while screen < target { + let previous = self.step(diff, file, self.top, false); + let next_screen = + screen + 1 + label_count(self.top, false) + label_count(previous, true) + - label_count(self.top, true); + if previous == self.top || next_screen > target { + break; + } + self.top = previous; + screen = next_screen; } - self.fill(diff, file, paint_height); + self.fill(diff, file, paint_height, syntax); } } } @@ -4592,7 +4702,14 @@ fn diff_screen_rows<'a>( (0..usize::from(height)).filter_map(move |screen| { if let Some(rows) = wrapped { rows.get(screen) - .map(|row| (screen, row.position.display, Some(row.segments))) + .filter(|row| row.scope == [false; 2]) + .map(|row| { + ( + screen, + row.position.display, + (!row.unwrapped).then_some(row.segments), + ) + }) } else { (top + screen < diff.display_rows.len()).then_some((screen, top + screen, None)) } @@ -4618,6 +4735,39 @@ struct DiffSyntaxCache { } impl DiffSyntaxCache { + fn scopes<'a>( + &'a self, + diff: &DiffState, + display: usize, + ) -> [Option>; 2] { + [Focus::Before, Focus::After].map(|focus| { + let source = diff.display_source_row(focus, display)?; + let syntax = if focus == Focus::Before { + &self.before + } else { + &self.after + }; + syntax.scope_at(source) + }) + } + + fn labels(&self, diff: &DiffState, position: DiffWrapPosition, sticky: bool) -> [[bool; 2]; 2] { + let scopes = self.scopes(diff, position.display); + if !sticky + && (position.continuation > 0 + || position.display > 0 && scopes == self.scopes(diff, position.display - 1)) + { + return [[false; 2]; 2]; + } + if diff.view_mode == ViewMode::Unified + && scopes[0].map(|scope| scope.label) != scopes[1].map(|scope| scope.label) + { + [[scopes[0].is_some(), false], [false, scopes[1].is_some()]] + } else { + [[scopes[0].is_some(), scopes[1].is_some()], [false; 2]] + } + } + fn update( &mut self, comparison_id: u64, @@ -4692,12 +4842,15 @@ fn diff_visible_rows( } else { pane.height }; - let count = wrapped.map_or(usize::from(height), |rows| { - rows.last().map_or(0, |row| row.position.display - top + 1) - }); - for display in top..top.saturating_add(count).min(diff.display_rows.len()) { + for (_, display, _) in diff_screen_rows(diff, top, height, wrapped) { let source = diff.display_source_row(if before { Focus::Before } else { Focus::After }, display); + if ranges + .last() + .is_some_and(|range| source.is_some_and(|source| range.contains(&source))) + { + continue; + } if let Some(source) = source { if let Some(last) = ranges.last_mut() && last.end == source @@ -7741,6 +7894,85 @@ mod tests { } } + #[test] + fn diff_scope_labels_identify_multiple_scopes_and_sticky_renamed_sides() { + let before = "impl Review {\n fn old_name(&self) {\n let first = 1;\n let second = 2;\n }\n fn next(&self) {\n let third = 3;\n }\n}"; + let after = before + .replace("old_name", "new_name") + .replace("first = 1", "first = 9"); + for axis in [SplitAxis::Vertical, SplitAxis::Horizontal] { + for wrapping in [false, true] { + let mut diff = diff_comparison(vec![syntax_diff_file("scope.rs", before, &after)]); + diff.files_visible = false; + diff.split_axis = axis; + diff.word_wrap = wrapping; + let mut terminal = + Terminal::new(ratatui::backend::TestBackend::new(160, 48)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + let rows = &panels.diff_wrap.as_ref().unwrap().rows; + for (side, name, other) in + [(0, "old_name", "new_name"), (1, "new_name", "old_name")] + { + let area = diff_inner(panels.diff.as_ref().unwrap().regions[side + 1].0); + let labels = rows + .iter() + .enumerate() + .filter(|(_, row)| row.scope[side]) + .map(|(screen, _)| { + (area.x..area.right()) + .map(|x| { + terminal.backend().buffer()[(x, area.y + screen as u16)] + .symbol() + }) + .collect::() + }) + .collect::>(); + assert!(labels.iter().any(|label| label.contains(name))); + assert!(labels.iter().any(|label| label.contains("next"))); + assert!(!labels.iter().any(|label| label.contains(other))); + } + let syntax = panels.diff_syntax.as_ref().unwrap(); + let parses = (syntax.before.parse_counts(), syntax.after.parse_counts()); + diff.top = diff.source_display_row(Focus::Before, 2).unwrap(); + render_diff(&mut terminal, &diff, &mut panels); + let rows = &panels.diff_wrap.as_ref().unwrap().rows; + assert_eq!(rows[0].scope, [true; 2]); + assert_eq!(rows[1].scope, [false; 2]); + assert_eq!(rows[0].position, rows[1].position); + let first = diff_screen_rows(&diff, diff.top, 48, Some(rows)) + .next() + .unwrap(); + assert_eq!((first.0, first.1), (1, diff.top)); + let syntax = panels.diff_syntax.as_ref().unwrap(); + assert_eq!( + (syntax.before.parse_counts(), syntax.after.parse_counts()), + parses + ); + } + } + let mut narrow = diff_comparison(vec![syntax_diff_file("scope.rs", before, &after)]); + narrow.files_visible = false; + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 30)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &narrow, &mut panels); + assert!(screen_text(&terminal).contains("› old_name")); + assert!(screen_text(&terminal).contains("› new_name")); + let diff = diff_comparison(vec![syntax_diff_file("scope.txt", before, &after)]); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap(); + let mut panels = WheelPanels::default(); + render_diff(&mut terminal, &diff, &mut panels); + assert!( + panels + .diff_wrap + .as_ref() + .unwrap() + .rows + .iter() + .all(|row| row.scope == [false; 2]) + ); + } + #[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(); @@ -7754,11 +7986,19 @@ mod tests { diff_comparison(vec![syntax_diff_file("working.rs", "fn old() {}", source)]); diff.files_visible = false; render_diff(&mut terminal, &diff, &mut panels); + let source_row = panels + .diff_wrap + .as_ref() + .unwrap() + .rows + .iter() + .position(|row| row.scope == [false; 2]) + .unwrap() as u16; let after = panels.diff.as_ref().unwrap().regions[2].0; - let cell = &terminal.backend().buffer()[(after.x + 4, after.y + 1)]; + let cell = &terminal.backend().buffer()[(after.x + 4, after.y + 1 + source_row)]; assert_eq!(cell.fg, foreground, "{source}"); let before = panels.diff.as_ref().unwrap().regions[1].0; - let cell = &terminal.backend().buffer()[(before.x + 4, before.y + 1)]; + let cell = &terminal.backend().buffer()[(before.x + 4, before.y + 1 + source_row)]; assert_eq!(cell.fg, Color::Magenta); } } @@ -7795,11 +8035,30 @@ 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 in [ + for (side, 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)]; + ] + .into_iter() + .enumerate() + { + let focus = if side == 0 { + Focus::Before + } else { + Focus::After + }; + let screen = panels + .diff_wrap + .as_ref() + .unwrap() + .rows + .iter() + .position(|row| { + row.scope == [false; 2] + && diff.display_source_row(focus, row.position.display) == Some(1) + }) + .unwrap(); + let cell = &terminal.backend().buffer()[(region.x + 4, region.y + 1 + screen as u16)]; assert_eq!(cell.fg, Color::Magenta); } } @@ -8707,6 +8966,14 @@ mod tests { let geometry = panels.diff.as_ref().unwrap(); let before = geometry.regions[1].0; let after = geometry.regions[2].0; + let source_row = panels + .diff_wrap + .as_ref() + .unwrap() + .rows + .iter() + .position(|row| row.scope == [false; 2]) + .unwrap() as u16; let buffer = terminal.backend().buffer(); for pane in [before, after] { for (column, row, foreground) in [ @@ -8721,32 +8988,70 @@ mod tests { (23, 2, Color::DarkGray), ] { assert_eq!( - buffer[(pane.x + 4 + column, pane.y + 1 + row)].fg, + buffer[(pane.x + 4 + column, pane.y + 1 + source_row + 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"); + assert_eq!( + buffer[(pane.x + 20, pane.y + 2 + source_row)].symbol(), + "界" + ); + assert_eq!( + buffer[(pane.x + 22, pane.y + 2 + source_row)].symbol(), + "e\u{301}" + ); + assert_eq!(buffer[(pane.x + 26, pane.y + 3 + source_row)].symbol(), "�"); + assert_eq!(buffer[(pane.x + 27, pane.y + 3 + source_row)].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); + assert_eq!(buffer[(x, pane.y + 4 + source_row)].symbol(), " "); + assert_eq!(buffer[(x, pane.y + 4 + source_row)].fg, Color::Reset); } } diff.top = 1; diff.left_before = 17; diff.left_after = 16; render_diff(&mut terminal, &diff, &mut panels); + let source_row = panels + .diff_wrap + .as_ref() + .unwrap() + .rows + .iter() + .position(|row| row.scope == [false; 2]) + .unwrap() as u16; 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 + 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 + 6, after.y + 1)].symbol(), "e\u{301}"); - assert_eq!(buffer[(after.x + 6, after.y + 1)].fg, Color::Green); + assert_eq!( + buffer[(before.x + 4, before.y + 1 + source_row)].symbol(), + " " + ); + assert_eq!( + buffer[(before.x + 4, before.y + 1 + source_row)].fg, + Color::Reset + ); + assert_eq!( + buffer[(before.x + 5, before.y + 1 + source_row)].symbol(), + "e\u{301}" + ); + assert_eq!( + buffer[(before.x + 5, before.y + 1 + source_row)].fg, + Color::Green + ); + assert_eq!( + buffer[(after.x + 4, after.y + 1 + source_row)].symbol(), + "界" + ); + assert_eq!( + buffer[(after.x + 4, after.y + 1 + source_row)].fg, + Color::Green + ); + assert_eq!( + buffer[(after.x + 6, after.y + 1 + source_row)].symbol(), + "e\u{301}" + ); + assert_eq!( + buffer[(after.x + 6, after.y + 1 + source_row)].fg, + Color::Green + ); } #[test] @@ -12398,6 +12703,112 @@ end terminal.get_cursor_position().unwrap() } + #[test] + fn scope_projection_keeps_unified_wrapped_caret_and_selection_source_only() { + let before = format!( + "fn old_name() {{\n // {}\n}}\nfn second() {{}}\n", + "word ".repeat(45) + ); + let after = before.replace("old_name", "new_name"); + for axis in [SplitAxis::Vertical, SplitAxis::Horizontal] { + let mut editor = readonly_diff_editor(syntax_diff_file("scope.rs", &before, &after)); + if axis == SplitAxis::Horizontal { + editor.execute("diff.orientation.toggle"); + } + editor.handle(Input::Char('\t')); + editor.handle(Input::Down); + editor.handle(Input::Home); + editor.handle(Input::Char('v')); + editor.handle(Input::End); + let source = editor.cursor(); + let selections = editor.selections(); + let text = editor.lines().to_vec(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap(); + let mut views = BufferViewports::default(); + for command in [ + "", + "word_wrap.toggle", + "diff.view.toggle", + "word_wrap.toggle", + "diff.view.toggle", + ] { + if !command.is_empty() { + editor.execute(command); + } + let caret = render_native_diff(&mut terminal, &mut views, &mut editor); + assert_eq!(editor.cursor(), source); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.lines(), text); + let geometry = views.panels.diff.as_ref().unwrap(); + let diff = editor.diff().unwrap(); + let active = usize::from(diff.focus == Focus::After); + let area = diff_inner(geometry.regions[active + 1].0); + assert!(area.contains(caret)); + let rows = &views.panels.diff_wrap.as_ref().unwrap().rows; + let caret_row = usize::from(caret.y - area.y); + assert_eq!(rows[caret_row].scope, [false; 2]); + assert_eq!( + diff.display_source_row(diff.focus, rows[caret_row].position.display), + Some(source.row) + ); + assert!(rows.iter().any(|row| row.scope != [false; 2])); + if diff.view_mode == ViewMode::Unified { + let labels = rows + .iter() + .enumerate() + .filter(|(_, row)| row.scope != [false; 2]) + .map(|(screen, _)| { + (area.x..area.right()) + .map(|x| { + terminal.backend().buffer()[(x, area.y + screen as u16)] + .symbol() + }) + .collect::() + }) + .collect::>(); + assert!(labels.iter().any(|label| label.contains("− old_name"))); + assert!(labels.iter().any(|label| label.contains("+ new_name"))); + } + for (screen, row) in rows.iter().take(usize::from(area.height)).enumerate() { + if row.scope == [false; 2] { + continue; + } + let y = area.y + screen as u16; + for x in area.x..area.right() { + assert_ne!(terminal.backend().buffer()[(x, y)].bg, Color::Blue); + } + for x in area.x..area.right() - geometry.text_widths[active] { + assert_eq!(terminal.backend().buffer()[(x, y)].symbol(), " "); + } + } + } + } + } + + #[test] + fn tiny_horizontal_scope_diff_keeps_active_source_visible() { + let mut editor = readonly_diff_editor(syntax_diff_file( + "small.rs", + "fn old_name() {}", + "fn new_name() {}", + )); + editor.execute("diff.orientation.toggle"); + editor.handle(Input::Char('\t')); + editor.handle(Input::Char('\t')); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 9)).unwrap(); + let mut views = BufferViewports::default(); + let caret = render_native_diff(&mut terminal, &mut views, &mut editor); + let area = diff_inner(views.panels.diff.as_ref().unwrap().regions[2].0); + assert!( + area.contains(caret), + "source caret {caret:?} outside {area:?}" + ); + assert_eq!( + views.panels.diff_wrap.as_ref().unwrap().rows[usize::from(caret.y - area.y)].scope, + [false; 2] + ); + } + #[test] fn modified_sequences_navigate_diff_files_and_readonly_panes() { let mut editor = Editor::open(None, vec![]).unwrap(); @@ -12688,7 +13099,17 @@ end let text = editor.lines().to_vec(); editor.execute("word_wrap.toggle"); render_native_diff(&mut terminal, &mut views, &mut editor); - assert!(views.panels.diff_wrap.is_none()); + assert!(!editor.word_wrap()); + assert!( + views + .panels + .diff_wrap + .as_ref() + .unwrap() + .rows + .iter() + .all(|row| row.position.continuation == 0) + ); assert_eq!(editor.selections(), selection); assert_eq!(editor.lines(), text); assert_eq!(editor.mode(), Mode::Visual);