feat: anchor review comments to precise text ranges

This commit is contained in:
Valdemar Lorenzen
2026-10-16 14:45:43 +02:00
parent 9a24302b4e
commit 2259d61633
11 changed files with 687 additions and 74 deletions
+5 -2
View File
@@ -6,12 +6,14 @@
- save and reopen reviews using local files that coding agents can read; no server or client/server protocol. - save and reopen reviews using local files that coding agents can read; no server or client/server protocol.
- anchor comments to immutable revision IDs, paths, sides, and source line ranges, never screen coordinates or mutable file indices. - anchor comments to immutable revision IDs, paths, sides, and source line ranges, never screen coordinates or mutable file indices.
- preserve original anchors and mark comments stale when the reviewed snapshot no longer matches; defer automatic reanchoring. - preserve original anchors and mark comments stale when the reviewed snapshot no longer matches; defer automatic reanchoring.
- [ ] diff view: attach local review comments to arbitrary contiguous text ranges, not only containing lines. - [ ] add an option for three pane lef/base/right view for conflict resolution.
- alternative pane layout as left/right with base underneath (configurable)
- [x] diff view: attach local review comments to arbitrary contiguous text ranges, not only containing lines.
- preserve exact start/end source positions in grapheme coordinates with an exclusive end, including partial-line and multiline selections on either diff side. - preserve exact start/end source positions in grapheme coordinates with an exclusive end, including partial-line and multiline selections on either diff side.
- characterwise Visual (`v`) anchors the exact selected text; Visual Line (`V`) anchors whole selected lines; no selection retains current-line behavior. - characterwise Visual (`v`) anchors the exact selected text; Visual Line (`V`) anchors whole selected lines; no selection retains current-line behavior.
- retain gutter markers for discovery and highlight the exact anchored text when its comment is selected. - retain gutter markers for discovery and highlight the exact anchored text when its comment is selected.
- preserve immutable snapshot identity and existing stale detection; never silently reanchor comments after source changes. - preserve immutable snapshot identity and existing stale detection; never silently reanchor comments after source changes.
- persist precise ranges in SQLite and JSON import/export; migrate existing line-only anchors without losing comments. - persist precise ranges in version 2 JSON import/export; migrate existing line-only anchors without losing comments. SQLite storage follows in the next item.
- future GitHub publishing may map precise local anchors to supported line ranges without reducing precision in the local review. - future GitHub publishing may map precise local anchors to supported line ranges without reducing precision in the local review.
- [ ] persist local reviews automatically in a local SQLite database using rusqlite; no server, hosted storage, or synchronization. - [ ] persist local reviews automatically in a local SQLite database using rusqlite; no server, hosted storage, or synchronization.
- keep one database under $XDG_DATA_HOME/vamp/reviews/, falling back to ~/.local/share/vamp/reviews/; allow a configurable review_directory. - keep one database under $XDG_DATA_HOME/vamp/reviews/, falling back to ~/.local/share/vamp/reviews/; allow a configurable review_directory.
@@ -21,6 +23,7 @@
- retain explicit JSON export/import for sharing with coding agents; export must not redirect subsequent autosaves away from the database. - retain explicit JSON export/import for sharing with coding agents; export must not redirect subsequent autosaves away from the database.
- version the database schema and migrate it safely; use review-version checks to reject conflicting edits from multiple editor instances. - version the database schema and migrate it safely; use review-version checks to reject conflicting edits from multiple editor instances.
- surface write failures and conflicts without discarding drafts or marking unsuccessful saves as persisted. - surface write failures and conflicts without discarding drafts or marking unsuccessful saves as persisted.
- persist the precise start/end grapheme anchors without reducing them to line ranges.
- [ ] diff view: GitHub code review integration after the local review UX is usable. - [ ] diff view: GitHub code review integration after the local review UX is usable.
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes. - publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission. - preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
+1
View File
@@ -17,6 +17,7 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
- `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. - `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. - `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.
- `review`: owns versioned local JSON review documents, immutable source anchors, validation, bounded reads, and atomic writes with external-change protection. It has no terminal or network operations. Comparison state owns the active review; `editor_review` keeps native comment/summary buffers and the read-only overview attached to their owning tab, synchronizes drafts on review commands, and guards unsaved teardown. The floating review editor has a tab-owned native pane outside the workspace `PaneTree`; rendering overlays it on the diff owner's rectangle without changing the source layout. Dismissal retains drafts and restores source focus. Working snapshot IDs hash the private captured paths, modes, contents, and notices; anchors also retain exact file text to account for live edits without reanchoring. - `review`: owns versioned local JSON review documents, immutable source anchors, validation, bounded reads, and atomic writes with external-change protection. It has no terminal or network operations. Comparison state owns the active review; `editor_review` keeps native comment/summary buffers and the read-only overview attached to their owning tab, synchronizes drafts on review commands, and guards unsaved teardown. The floating review editor has a tab-owned native pane outside the workspace `PaneTree`; rendering overlays it on the diff owner's rectangle without changing the source layout. Dismissal retains drafts and restores source focus. Working snapshot IDs hash the private captured paths, modes, contents, and notices; anchors also retain exact file text to account for live edits without reanchoring.
Version 2 anchors use the existing `plugin::TextPosition` type for zero-based grapheme start/end positions with an exclusive end. The JSON loading boundary migrates strict version 1 line-only records into this model while retaining the original disk bytes for external-write checks. Live code has no legacy coordinate aliases. Selected-comment painting reuses source-side grapheme clipping and wrapping; native selections retain higher priority.
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets. - `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. - `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.
- `markdown`: owns native Markdown link/reference resolution, local destination decoding, and heading fragment lookup through the installed block/inline grammars. It has no editor lifecycle, terminal, LSP, or external-process dependency. - `markdown`: owns native Markdown link/reference resolution, local destination decoding, and heading fragment lookup through the installed block/inline grammars. It has no editor lifecycle, terminal, LSP, or external-process dependency.
+1
View File
@@ -39,6 +39,7 @@ Rust tests live alongside their modules; native LSP integration checks are in [`
- Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity. - Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity.
- Visual-command checks preserve directed characterwise/linewise ranges and multiple carets through command cancellation and plugin dispatch, exercise remapped command entry, and submit `:diff.comment` on either historical side. Live PTY smoke selects lines, cancels a prompt, then runs `:diff.comment` directly and verifies the saved original range without using the palette. - Visual-command checks preserve directed characterwise/linewise ranges and multiple carets through command cancellation and plugin dispatch, exercise remapped command entry, and submit `:diff.comment` on either historical side. Live PTY smoke selects lines, cancels a prompt, then runs `:diff.comment` directly and verifies the saved original range without using the palette.
- Presentation regressions render retained characterwise/linewise highlights during command entry in ordinary, split-diff, and unified views. Floating-review checks preserve source rectangles and the workspace layout, place the caret inside the overlay, retain dismissed drafts across tab switches, and restore source focus on Escape or pane close. Live PTY smoke edits, dismisses, reopens, and saves a floating comment and summary before returning to the unchanged diff. - Presentation regressions render retained characterwise/linewise highlights during command entry in ordinary, split-diff, and unified views. Floating-review checks preserve source rectangles and the workspace layout, place the caret inside the overlay, retain dismissed drafts across tab switches, and restore source focus on Escape or pane close. Live PTY smoke edits, dismisses, reopens, and saves a floating comment and summary before returning to the unchanged diff.
- Precise-range checks cover forward/reversed characterwise and partial-multiline selections on both sides, linewise and caret/empty-line fallbacks, source-column navigation, Unicode grapheme bounds, strict v1-to-v2 JSON migration, invalid/mixed schemas, and preserved save-conflict checks. Render checks cover exact text/newline cells, wrapping, horizontal clipping, shared unified context, stale suppression, and native Visual precedence. Live PTY smoke saves a selection containing a wide and combining grapheme, then imports and rewrites a legacy line-only review without changing its body.
- Markdown navigation checks cover inline/reference destinations, escaped/percent-encoded paths, heading markup and duplicates, same-document fragments, dirty target reuse, failed navigation atomicity, primary-only following, and `gf` prefix cancellation/configuration overrides. - Markdown navigation checks cover inline/reference destinations, escaped/percent-encoded paths, heading markup and duplicates, same-document fragments, dirty target reuse, failed navigation atomicity, primary-only following, and `gf` prefix cancellation/configuration overrides.
- Jump-history checks cover cross-buffer/same-document navigation, search and picker recording, counts and repeated visits, bounded history, branch truncation, closed buffers, edited-coordinate clamping, dirty text/undo preservation, independent Ctrl+i/Tab bindings, and custom bindings. Diff checks exercise Ctrl+i on either side in split/unified views, both control-letter and modified-Tab terminal events, and plain Tab pane cycling. Real LSP fixtures cover definition round-trips and delayed replies after leaving and returning to the same source location. - Jump-history checks cover cross-buffer/same-document navigation, search and picker recording, counts and repeated visits, bounded history, branch truncation, closed buffers, edited-coordinate clamping, dirty text/undo preservation, independent Ctrl+i/Tab bindings, and custom bindings. Diff checks exercise Ctrl+i on either side in split/unified views, both control-letter and modified-Tab terminal events, and plain Tab pane cycling. Real LSP fixtures cover definition round-trips and delayed replies after leaving and returning to the same source location.
- Tab checks cover shared edits/history with independent pane layouts, focused views, explorers and viewports; cross-tab close guards, immutable snapshots, hidden comparison refresh/result routing, and separate repository workers. Rendered checks cover sanitized/dirty titles, active-tab visibility in narrow terminals, overlay row reservation, and exclusion of hidden diff geometry from palette polling and viewport reporting. - Tab checks cover shared edits/history with independent pane layouts, focused views, explorers and viewports; cross-tab close guards, immutable snapshots, hidden comparison refresh/result routing, and separate repository workers. Rendered checks cover sanitized/dirty titles, active-tab visibility in narrow terminals, overlay row reservation, and exclusion of hidden diff geometry from palette polling and viewport reporting.
+4 -4
View File
@@ -452,13 +452,13 @@ Collapsed folders show a green **`✓`** when every changed file beneath them is
Review marks belong only to the open comparison and survive filtering, file navigation, lazy loading, tabs, split/unified/orientation changes, and pinned checkout-only transitions. A new or recomputed comparison clears them. Editing a writable After file clears only that file's mark; unchanged refreshes and saves preserve it. Marks do not change document text, undo history, or repository state, and are not persisted after closing the comparison. Review marks belong only to the open comparison and survive filtering, file navigation, lazy loading, tabs, split/unified/orientation changes, and pinned checkout-only transitions. A new or recomputed comparison clears them. Editing a writable After file clears only that file's mark; unchanged refreshes and saves preserve it. Marks do not change document text, undo history, or repository state, and are not persisted after closing the comparison.
**Local review comments** are independent of GitHub and the file-reviewed checkmarks above. Focus either diff text pane and run **`:diff.comment`** to comment at the caret. For a range, make one Visual or Visual Line selection, then type **`:diff.comment`** and press Enter. The palette and configured Visual-mode bindings also work. Multiple independent carets/ranges must be reviewed separately. **Local review comments** are independent of GitHub and the file-reviewed checkmarks above. Focus either diff text pane and run **`:diff.comment`** to comment on the current line. A characterwise Visual selection (`v`) anchors its exact text, including partial lines and multiline spans; Visual Line (`V`) anchors whole selected lines. Type **`:diff.comment`** and press Enter, or use the palette/a configured binding. Multiple independent carets/ranges must be reviewed separately.
Comments, summaries, and the review overview open in a focused floating editor over the whole diff text workspace, not a source-side split. The source layout, file explorer, and viewports stay unchanged. Normal editing, selections, and undo/redo work in editable review buffers. Escape in Normal mode or `:pane.close` dismisses the window and returns to the diff; drafts remain available for reopening and saving. Insert/Visual/Command Escape first retains its usual modal behavior. Source rows are never replaced with comment text. Magenta **`○`** gutter markers identify current comment ranges; **`●`** identifies the selected comment. Markers preserve addition/deletion signs, appear only on the first wrapped segment, and also work in unified view. Comments, summaries, and the review overview open in a focused floating editor over the whole diff text workspace, not a source-side split. The source layout, file explorer, and viewports stay unchanged. Normal editing, selections, and undo/redo work in editable review buffers. Escape in Normal mode or `:pane.close` dismisses the window and returns to the diff; drafts remain available for reopening and saving. Insert/Visual/Command Escape first retains its usual modal behavior. Source rows are never replaced with comment text. Magenta **`○`** gutter markers identify comment ranges; **`●`** identifies the selected comment. Its exact current text range has a magenta background, including wrapped continuations and selected newline cells; native Visual selections take precedence. Stale comments have neither markers nor text highlighting. Markers preserve addition/deletion signs, appear only on the first wrapped segment, and also work in unified view.
| Command | Effect | | Command | Effect |
| --- | --- | | --- | --- |
| `:diff.comment` | Add a comment on the current side and caret/selected line range | | `:diff.comment` | Add a comment on the current line or exact selected text range, on the focused side |
| `:diff.review.edit [id]` | Edit an existing comment; defaults to the overview entry under the caret or selected comment | | `:diff.review.edit [id]` | Edit an existing comment; defaults to the overview entry under the caret or selected comment |
| `:diff.review.delete [id]` | Delete that draft comment | | `:diff.review.delete [id]` | Delete that draft comment |
| `:diff.review.next` / `:diff.review.previous` | Cycle comments, opening their text and revealing a matching source anchor | | `:diff.review.next` / `:diff.review.previous` | Cycle comments, opening their text and revealing a matching source anchor |
@@ -471,7 +471,7 @@ Comments, summaries, and the review overview open in a focused floating editor o
In a review text pane, **`:w` saves the entire review**, not a source file. Choose a destination with `:diff.review.save path` first, or use `:w path`. Saving includes edits in hidden comment/summary buffers. Normal close, quit, restart, comparison replacement, and source-buffer deletion protect unsaved reviews; forced close/quit retains its explicit discard semantics. An externally changed review file is never silently overwritten, including with `:w!`; use a new path or reopen it after preserving your edits. In a review text pane, **`:w` saves the entire review**, not a source file. Choose a destination with `:diff.review.save path` first, or use `:w path`. Saving includes edits in hidden comment/summary buffers. Normal close, quit, restart, comparison replacement, and source-buffer deletion protect unsaved reviews; forced close/quit retains its explicit discard semantics. An externally changed review file is never silently overwritten, including with `:w!`; use a new path or reopen it after preserving your edits.
The versioned JSON is the canonical, agent-readable artifact: repository root, summary, optional verdict, and comments with stable IDs, bodies, and anchors. Each anchor retains its immutable comparison revision, side, relative path (old path for renamed Before files), inclusive one-based line range, and exact whole-file text including the final newline. Working comparisons use content-addressed snapshot IDs; captured text additionally freezes any live unsaved edits. The file is limited to 64 MiB. Prefer saving outside the checkout so the review itself does not become another changed file. Version 2 JSON is the canonical, agent-readable artifact: repository root, summary, optional verdict, and comments with stable IDs, bodies, and anchors. Each anchor retains its immutable comparison revision, side, relative path (old path for renamed Before files), and exact whole-file text including the final newline. `start` and `end` contain zero-based source `row` and extended-grapheme `column` coordinates; `end` is exclusive. Columns count complete Unicode graphemes, not bytes, code points, UTF-16 units, or screen cells. Overview/window labels display one-based rows and columns. Existing version 1 line-only reviews load as equivalent whole-line spans, preserving IDs, text, verdicts, and snapshot identity; the next explicit save writes version 2. Working comparisons use content-addressed snapshot IDs; captured text additionally freezes live unsaved edits. The file limit remains 64 MiB. Prefer saving outside the checkout so the review itself does not become another changed file.
Reopening requires the same repository root. Comments whose revision, path, or exact file text no longer matches are **stale**: their original anchors remain unchanged and their gutter markers disappear. Unloaded files are shown as **not checked** until visited; refresh the overview to update its statuses. There is no automatic reanchoring, server, authentication, or review-publishing operation. Reopening requires the same repository root. Comments whose revision, path, or exact file text no longer matches are **stale**: their original anchors remain unchanged and their gutter markers disappear. Unloaded files are shown as **not checked** until visited; refresh the overview to update its statuses. There is no automatic reanchoring, server, authentication, or review-publishing operation.
+2 -2
View File
@@ -5033,8 +5033,8 @@ mod tests {
revision: "working:snapshot".into(), revision: "working:snapshot".into(),
path: "file".into(), path: "file".into(),
side: Side::After, side: Side::After,
start_line: 1, start: crate::plugin::TextPosition { row: 0, column: 0 },
end_line: 1, end: crate::plugin::TextPosition { row: 1, column: 0 },
content: "new\n".into(), content: "new\n".into(),
}; };
assert!(state.review_anchor_current(&anchor)); assert!(state.review_anchor_current(&anchor));
+78 -1
View File
@@ -15990,6 +15990,82 @@ mod tests {
); );
} }
#[test]
fn precise_review_ranges_capture_graphemes_and_navigate_to_their_start() {
for focus in [Focus::Before, Focus::After] {
for keys in ["lvl", "llvh", "llvj"] {
let directory = Directory::new();
let root = directory.0.canonicalize().unwrap();
let mut editor = editor();
historical_diff(
&mut editor,
&root,
vec!["p界e\u{301}z".into(), "before".into()],
vec!["q界e\u{301}z".into(), "after!".into()],
);
editor.diff.as_mut().unwrap().focus = focus;
editor.bind_diff_buffer();
let owner = editor.pane_id();
type_text(&mut editor, keys);
type_text(&mut editor, ":diff.comment");
editor.handle(Input::Enter);
let diff = editor.workspace_diff().unwrap();
let anchor = &diff.local_review.document.comments[0].anchor;
let (start, end) = if keys == "llvj" {
(
TextPosition { row: 0, column: 2 },
TextPosition { row: 1, column: 3 },
)
} else {
(
TextPosition { row: 0, column: 1 },
TextPosition { row: 0, column: 3 },
)
};
assert_eq!(anchor.start, start);
assert_eq!(anchor.end, end);
assert!(diff.review_anchor_current(anchor));
editor.handle(Input::Escape);
assert_eq!(editor.pane_id(), owner);
assert_eq!(
editor.cursor(),
Cursor {
row: start.row,
column: start.column
}
);
}
}
for text in ["last", "one\nlast", "one\n", ""] {
let directory = Directory::new();
let root = directory.0.canonicalize().unwrap();
let mut editor = editor();
historical_diff(&mut editor, &root, vec!["before".into()], split_lines(text));
editor.diff.as_mut().unwrap().focus = Focus::After;
editor.bind_diff_buffer();
editor.execute("diff.comment");
let anchor = &editor
.workspace_diff()
.unwrap()
.local_review
.document
.comments[0]
.anchor;
assert_eq!(anchor.start, TextPosition { row: 0, column: 0 });
assert_eq!(
anchor.end,
if text.contains('\n') {
TextPosition { row: 1, column: 0 }
} else {
TextPosition {
row: 0,
column: grapheme_count(text),
}
}
);
}
}
#[test] #[test]
fn visual_commands_preserve_directed_ranges_on_cancel_and_plugin_dispatch() { fn visual_commands_preserve_directed_ranges_on_cancel_and_plugin_dispatch() {
for shape in ["vll", "vhh", "Vj"] { for shape in ["vll", "vhh", "Vj"] {
@@ -16059,7 +16135,8 @@ mod tests {
editor.handle(Input::Enter); editor.handle(Input::Enter);
let diff = editor.workspace_diff().unwrap(); let diff = editor.workspace_diff().unwrap();
let anchor = diff.local_review.document.comments[0].anchor.clone(); let anchor = diff.local_review.document.comments[0].anchor.clone();
assert_eq!((anchor.start_line, anchor.end_line), (1, 2)); assert_eq!(anchor.start, TextPosition { row: 0, column: 0 });
assert_eq!(anchor.end, TextPosition { row: 2, column: 0 });
assert_eq!( assert_eq!(
anchor.side, anchor.side,
if focus == Focus::Before { if focus == Focus::Before {
+38 -18
View File
@@ -341,14 +341,24 @@ impl Editor {
.filter(|_| self.diff_interacting()) .filter(|_| self.diff_interacting())
.ok_or("Focus a diff text pane to add a comment")?; .ok_or("Focus a diff text pane to add a comment")?;
if self.selection_count() != 1 { if self.selection_count() != 1 {
return Err("Use one caret or one line range for a review comment".into()); return Err("Use one caret or one contiguous selection for a review comment".into());
}
let range = self.selections().into_iter().next().unwrap_or_else(|| {
let row = self.cursor().row;
Selection {
start: Cursor { row, column: 0 },
end: if row + 1 < self.active.lines.len() {
Cursor {
row: row + 1,
column: 0,
}
} else {
Cursor {
row,
column: grapheme_count(&self.active.lines[row]),
}
},
} }
let selection = self.selections().into_iter().next();
let (first, last) = selection.map_or((self.cursor().row, self.cursor().row), |selection| {
let last = selection.end.row.saturating_sub(usize::from(
selection.end.column == 0 && selection.end.row > selection.start.row,
));
(selection.start.row, last)
}); });
let diff = self.diff.as_ref().unwrap(); let diff = self.diff.as_ref().unwrap();
let comparison = diff.comparison.as_ref().unwrap(); let comparison = diff.comparison.as_ref().unwrap();
@@ -367,8 +377,14 @@ impl Editor {
} }
.clone(), .clone(),
side, side,
start_line: first + 1, start: TextPosition {
end_line: last + 1, row: range.start.row,
column: range.start.column,
},
end: TextPosition {
row: range.end.row,
column: range.end.column,
},
content: self.active.lines.join("\n"), content: self.active.lines.join("\n"),
}; };
let id = diff let id = diff
@@ -405,10 +421,12 @@ impl Editor {
let text = comment.body.clone(); let text = comment.body.clone();
let anchor = &comment.anchor; let anchor = &comment.anchor;
let title = format!( let title = format!(
"Comment #{id} · {}:{}–{} · {:?} · :w saves review", "Comment #{id} · {}:{}:{}–{}:{} · {:?} · :w saves review",
anchor.path.display(), anchor.path.display(),
anchor.start_line, anchor.start.row + 1,
anchor.end_line, anchor.start.column + 1,
anchor.end.row + 1,
anchor.end.column + 1,
anchor.side anchor.side
); );
let target = diff.comparison.as_ref().and_then(|comparison| { let target = diff.comparison.as_ref().and_then(|comparison| {
@@ -479,16 +497,16 @@ impl Editor {
self.set_message("Comment is stale; original snapshot anchor preserved"); self.set_message("Comment is stale; original snapshot anchor preserved");
return; return;
} }
let row = comment.anchor.start_line - 1; let position = comment.anchor.start;
let focus = if comment.anchor.side == Side::Before { let focus = if comment.anchor.side == Side::Before {
Focus::Before Focus::Before
} else { } else {
Focus::After Focus::After
}; };
diff.focus = focus; diff.focus = focus;
diff.reveal_source_row(focus, row); diff.reveal_source_row(focus, position.row);
self.bind_diff_buffer(); self.bind_diff_buffer();
self.place_navigation(TextPosition { row, column: 0 }); self.place_navigation(position);
} }
fn open_review_overview(&mut self) -> Result<(), String> { fn open_review_overview(&mut self) -> Result<(), String> {
@@ -529,11 +547,13 @@ impl Editor {
use std::fmt::Write as _; use std::fmt::Write as _;
write!( write!(
text, text,
"\n## Comment #{} · {status}\n{}:{}–{} · {:?}\nRevision: {}\n{}\n", "\n## Comment #{} · {status}\n{}:{}:{}–{}:{} · {:?}\nRevision: {}\n{}\n",
comment.id, comment.id,
anchor.path.display(), anchor.path.display(),
anchor.start_line, anchor.start.row + 1,
anchor.end_line, anchor.start.column + 1,
anchor.end.row + 1,
anchor.end.column + 1,
anchor.side, anchor.side,
anchor.revision, anchor.revision,
comment.body comment.body
+2 -1
View File
@@ -83,7 +83,8 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly
Remote refs/history load into temporary comparison repos without changing active refs/checkout Remote refs/history load into temporary comparison repos without changing active refs/checkout
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
Local reviews: :diff.comment at caret or on a Visual/Visual Line selection Local reviews: :diff.comment anchors exact Visual text, Visual Line rows, or the current line
Selected comments highlight their precise text; old line-only JSON reviews migrate on load
:diff.review.edit/delete [ID], :diff.review.next/previous, :diff.review.overview :diff.review.edit/delete [ID], :diff.review.next/previous, :diff.review.overview
:diff.review.summary; :diff.review.verdict looks_good|changes_requested|none :diff.review.summary; :diff.review.verdict looks_good|changes_requested|none
:diff.review.save PATH / :diff.review.open PATH; :w in floating review editors saves local JSON :diff.review.save PATH / :diff.review.open PATH; :w in floating review editors saves local JSON
+1 -1
View File
@@ -36,7 +36,7 @@ pub enum DiagnosticSeverity {
} }
/// Zero-based extended-grapheme positions, independent of any server encoding. /// Zero-based extended-grapheme positions, independent of any server encoding.
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd)] #[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, Ord, PartialEq, PartialOrd)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct TextPosition { pub struct TextPosition {
pub row: usize, pub row: usize,
+287 -18
View File
@@ -10,6 +10,7 @@ use std::{
atomic::{AtomicU64, Ordering}, atomic::{AtomicU64, Ordering},
}, },
}; };
use unicode_segmentation::UnicodeSegmentation;
const LIMIT: usize = 64 * 1024 * 1024; const LIMIT: usize = 64 * 1024 * 1024;
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
@@ -43,13 +44,19 @@ pub struct Anchor {
pub revision: String, pub revision: String,
pub path: PathBuf, pub path: PathBuf,
pub side: Side, pub side: Side,
pub start_line: usize, /// Zero-based extended-grapheme bounds; `end` is exclusive.
pub end_line: usize, pub start: crate::plugin::TextPosition,
pub end: crate::plugin::TextPosition,
/// Exact whole-side text, including its final newline when present. /// Exact whole-side text, including its final newline when present.
// ponytail: whole-file text per comment; deduplicate snapshots if the 64 MiB ceiling matters. // ponytail: whole-file text per comment; deduplicate snapshots if the 64 MiB ceiling matters.
pub content: String, pub content: String,
} }
impl Anchor { impl Anchor {
pub fn rows(&self) -> std::ops::RangeInclusive<usize> {
let end = self.end.row - usize::from(self.end.column == 0 && self.end.row > self.start.row);
self.start.row..=end
}
pub fn matches(&self, revision: &str, path: &Path, side: Side, text: &str) -> bool { pub fn matches(&self, revision: &str, path: &Path, side: Side, text: &str) -> bool {
self.revision == revision && self.path == path && self.side == side && self.content == text self.revision == revision && self.path == path && self.side == side && self.content == text
} }
@@ -91,6 +98,91 @@ pub struct Review {
#[serde(skip)] #[serde(skip)]
saved: RefCell<Option<(PathBuf, Arc<Vec<u8>>)>>, saved: RefCell<Option<(PathBuf, Arc<Vec<u8>>)>>,
} }
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyReview {
version: u32,
root: PathBuf,
summary: String,
verdict: Option<Verdict>,
comments: Vec<LegacyComment>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyComment {
id: u64,
anchor: LegacyAnchor,
body: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyAnchor {
revision: String,
path: PathBuf,
side: Side,
start_line: usize,
end_line: usize,
content: String,
}
impl LegacyReview {
fn migrate(self) -> Result<Review, String> {
if self.version != 1 {
return Err(format!("Unsupported review version {}", self.version));
}
let comments = self
.comments
.into_iter()
.map(|comment| {
let old = comment.anchor;
if old.start_line == 0 || old.end_line < old.start_line {
return Err("Legacy review ranges must be inclusive and nonzero".into());
}
let mut lines = old.content.split('\n');
let last = lines
.nth(old.end_line - 1)
.ok_or("Legacy review range is outside the anchor text")?;
let end = if lines.next().is_some() {
crate::plugin::TextPosition {
row: old.end_line,
column: 0,
}
} else {
crate::plugin::TextPosition {
row: old.end_line - 1,
column: last.graphemes(true).count(),
}
};
Ok(Comment {
id: comment.id,
anchor: Anchor {
revision: old.revision,
path: old.path,
side: old.side,
start: crate::plugin::TextPosition {
row: old.start_line - 1,
column: 0,
},
end,
content: old.content,
},
body: comment.body,
})
})
.collect::<Result<_, String>>()?;
Ok(Review {
version: 2,
root: self.root,
summary: self.summary,
verdict: self.verdict,
comments,
saved: RefCell::new(None),
})
}
}
impl Default for Review { impl Default for Review {
fn default() -> Self { fn default() -> Self {
Self::new(PathBuf::new()) Self::new(PathBuf::new())
@@ -114,7 +206,7 @@ impl Review {
fs::canonicalize(&root).unwrap_or(root) fs::canonicalize(&root).unwrap_or(root)
}; };
Self { Self {
version: 1, version: 2,
root, root,
summary: String::new(), summary: String::new(),
verdict: None, verdict: None,
@@ -124,7 +216,7 @@ impl Review {
} }
fn validate(&self) -> Result<(), String> { fn validate(&self) -> Result<(), String> {
if self.version != 1 { if self.version != 2 {
return Err(format!("Unsupported review version {}", self.version)); return Err(format!("Unsupported review version {}", self.version));
} }
if !self.root.is_absolute() || !self.root.is_dir() { if !self.root.is_absolute() || !self.root.is_dir() {
@@ -143,22 +235,49 @@ impl Review {
if anchor.revision.is_empty() || anchor.revision.chars().any(char::is_control) { if anchor.revision.is_empty() || anchor.revision.chars().any(char::is_control) {
return Err("Review anchors require an immutable revision identity".into()); return Err("Review anchors require an immutable revision identity".into());
} }
if anchor.start_line == 0 if anchor.start > anchor.end {
|| anchor.end_line < anchor.start_line return Err("Review ranges must be ordered and end-exclusive".into());
|| anchor.end_line > anchor.content.split('\n').count() }
let mut end_exists = false;
for (row, line) in anchor.content.split('\n').enumerate() {
if row == anchor.start.row || row == anchor.end.row {
let columns = line.graphemes(true).count();
if (row == anchor.start.row && anchor.start.column > columns)
|| (row == anchor.end.row && anchor.end.column > columns)
{ {
return Err( return Err(
"Review ranges must be inclusive, nonzero and within the anchor text".into(), "Review columns must be within the anchor's grapheme text".into()
); );
} }
} }
if row == anchor.end.row {
end_exists = true;
break;
}
}
if !end_exists {
return Err("Review rows must be within the anchor text".into());
}
}
Ok(()) Ok(())
} }
pub fn load(path: &Path, root: &Path) -> Result<Self, String> { pub fn load(path: &Path, root: &Path) -> Result<Self, String> {
let bytes = read_file(path)?.ok_or("Review file does not exist")?; let bytes = read_file(path)?.ok_or("Review file does not exist")?;
let mut review: Self = serde_json::from_slice(&bytes) #[derive(Deserialize)]
struct Version {
version: u32,
}
let Version { version } = serde_json::from_slice(&bytes)
.map_err(|error| format!("Invalid review JSON: {error}"))?; .map_err(|error| format!("Invalid review JSON: {error}"))?;
let mut review: Self = match version {
1 => serde_json::from_slice::<LegacyReview>(&bytes)
.map_err(|error| format!("Invalid review JSON: {error}"))?
.migrate()?,
2 => serde_json::from_slice(&bytes)
.map_err(|error| format!("Invalid review JSON: {error}"))?,
_ => return Err(format!("Unsupported review version {version}")),
};
review.validate()?; review.validate()?;
let expected = fs::canonicalize(root).map_err(|error| error.to_string())?; let expected = fs::canonicalize(root).map_err(|error| error.to_string())?;
let actual = fs::canonicalize(&review.root).map_err(|error| error.to_string())?; let actual = fs::canonicalize(&review.root).map_err(|error| error.to_string())?;
@@ -278,14 +397,19 @@ mod tests {
} }
} }
#[test] fn directory() -> Directory {
fn persistence_validates_anchors_and_preserves_files_on_failure() {
let directory = Directory(std::env::temp_dir().join(format!( let directory = Directory(std::env::temp_dir().join(format!(
"vamp-review-test-{}-{}", "vamp-review-test-{}-{}",
std::process::id(), std::process::id(),
NEXT_TEMP.fetch_add(1, Ordering::Relaxed), NEXT_TEMP.fetch_add(1, Ordering::Relaxed),
))); )));
fs::create_dir(&directory.0).unwrap(); fs::create_dir(&directory.0).unwrap();
directory
}
#[test]
fn persistence_validates_anchors_and_preserves_files_on_failure() {
let directory = directory();
let root = directory.0.canonicalize().unwrap(); let root = directory.0.canonicalize().unwrap();
let path = root.join("review.json"); let path = root.join("review.json");
let mut review = Review::new(root.clone()); let mut review = Review::new(root.clone());
@@ -297,8 +421,8 @@ mod tests {
revision: "working:immutable".into(), revision: "working:immutable".into(),
path: "source.rs".into(), path: "source.rs".into(),
side: Side::After, side: Side::After,
start_line: 1, start: crate::plugin::TextPosition { row: 0, column: 0 },
end_line: 2, end: crate::plugin::TextPosition { row: 2, column: 0 },
content: "first\nlast\n".into(), content: "first\nlast\n".into(),
}, },
body: "Please adjust this".into(), body: "Please adjust this".into(),
@@ -340,7 +464,7 @@ mod tests {
assert!(Review::new(root.clone()).save(&path).is_err()); assert!(Review::new(root.clone()).save(&path).is_err());
let valid = fs::read(&path).unwrap(); let valid = fs::read(&path).unwrap();
for (field, value) in [ for (field, value) in [
("version", serde_json::json!(2)), ("version", serde_json::json!(3)),
("root", serde_json::json!(root.join("other"))), ("root", serde_json::json!(root.join("other"))),
( (
"comments", "comments",
@@ -356,9 +480,9 @@ mod tests {
("path", serde_json::json!("../outside")), ("path", serde_json::json!("../outside")),
("path", serde_json::json!("/absolute")), ("path", serde_json::json!("/absolute")),
("path", serde_json::json!(".git/config")), ("path", serde_json::json!(".git/config")),
("start_line", serde_json::json!(0)), ("start", serde_json::json!({"row": 2, "column": 1})),
("start_line", serde_json::json!(3)), ("start", serde_json::json!({"row": 3, "column": 0})),
("end_line", serde_json::json!(4)), ("end", serde_json::json!({"row": 3, "column": 0})),
("revision", serde_json::json!("")), ("revision", serde_json::json!("")),
] { ] {
let mut invalid = serde_json::to_value(&loaded).unwrap(); let mut invalid = serde_json::to_value(&loaded).unwrap();
@@ -374,7 +498,7 @@ mod tests {
assert_eq!(fs::read(&path).unwrap(), b"external edit"); assert_eq!(fs::read(&path).unwrap(), b"external edit");
assert_eq!(loaded.summary, "Updated"); assert_eq!(loaded.summary, "Updated");
let invalid = root.join("invalid.json"); let invalid = root.join("invalid.json");
loaded.comments[0].anchor.start_line = 0; loaded.comments[0].anchor.start.row = usize::MAX;
assert!(loaded.save(&invalid).is_err()); assert!(loaded.save(&invalid).is_err());
assert!(!invalid.exists()); assert!(!invalid.exists());
let large = root.join("large.json"); let large = root.join("large.json");
@@ -392,4 +516,149 @@ mod tests {
assert_eq!(fs::read(&path).unwrap(), b"external edit"); assert_eq!(fs::read(&path).unwrap(), b"external edit");
} }
} }
#[test]
fn legacy_lines_migrate_losslessly_and_save_exact_v2_ranges() {
use crate::plugin::TextPosition;
let directory = directory();
let root = directory.0.canonicalize().unwrap();
let path = root.join("review.json");
let cases = [
("first\nlast\n", 1, 2, (0, 0), (2, 0), 0..=1),
("first\nlast", 2, 2, (1, 0), (1, 4), 1..=1),
("é👩‍🔬e\u{301}", 1, 1, (0, 0), (0, 3), 0..=0),
("first\n\n", 2, 2, (1, 0), (2, 0), 1..=1),
("first\n", 2, 2, (1, 0), (1, 0), 1..=1),
("", 1, 1, (0, 0), (0, 0), 0..=0),
];
let comments: Vec<_> = cases
.iter()
.enumerate()
.map(|(index, case)| {
serde_json::json!({
"id": index + 11,
"anchor": {
"revision": "base:immutable",
"path": "old/name.rs",
"side": "before",
"start_line": case.1,
"end_line": case.2,
"content": case.0,
},
"body": format!("Comment {index}"),
})
})
.collect();
let original = serde_json::json!({
"version": 1,
"root": root,
"summary": "Keep this summary",
"verdict": "looks_good",
"comments": comments,
});
fs::write(&path, serde_json::to_vec(&original).unwrap()).unwrap();
let migrated = Review::load(&path, &root).unwrap();
assert_eq!(migrated.version, 2);
assert_eq!(migrated.root, root);
assert_eq!(migrated.summary, "Keep this summary");
assert_eq!(migrated.verdict, Some(Verdict::LooksGood));
for (index, (comment, case)) in migrated.comments.iter().zip(&cases).enumerate() {
assert_eq!(comment.id, index as u64 + 11);
assert_eq!(comment.body, format!("Comment {index}"));
assert_eq!(comment.anchor.revision, "base:immutable");
assert_eq!(comment.anchor.path, Path::new("old/name.rs"));
assert_eq!(comment.anchor.side, Side::Before);
assert_eq!(comment.anchor.content, case.0);
assert_eq!(
comment.anchor.start,
TextPosition {
row: case.3.0,
column: case.3.1
}
);
assert_eq!(
comment.anchor.end,
TextPosition {
row: case.4.0,
column: case.4.1
}
);
assert_eq!(comment.anchor.rows(), case.5);
}
migrated.save(&path).unwrap();
let saved: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
assert_eq!(saved["version"], 2);
assert!(saved["comments"][0]["anchor"].get("start_line").is_none());
assert!(saved["comments"][0]["anchor"].get("end_line").is_none());
assert_eq!(
Review::load(&path, &root).unwrap().comments,
migrated.comments
);
for (start, end) in [(0, 1), (2, 1), (1, 4)] {
let mut invalid = original.clone();
invalid["comments"][0]["anchor"]["start_line"] = start.into();
invalid["comments"][0]["anchor"]["end_line"] = end.into();
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
assert!(Review::load(&path, &root).is_err());
}
let mut mixed = original.clone();
mixed["comments"][0]["anchor"]["start"] = serde_json::json!({"row": 0, "column": 0});
fs::write(&path, serde_json::to_vec(&mixed).unwrap()).unwrap();
assert!(Review::load(&path, &root).is_err());
let mut wrong_version = original;
wrong_version["version"] = 2.into();
fs::write(&path, serde_json::to_vec(&wrong_version).unwrap()).unwrap();
assert!(Review::load(&path, &root).is_err());
}
#[test]
fn exact_grapheme_ranges_roundtrip_and_reject_invalid_coordinates() {
use crate::plugin::TextPosition;
let directory = directory();
let root = directory.0.canonicalize().unwrap();
let path = root.join("review.json");
let mut review = Review::new(root.clone());
review.comments.push(Comment {
id: 1,
anchor: Anchor {
revision: "working:immutable".into(),
path: "source.rs".into(),
side: Side::After,
start: TextPosition { row: 0, column: 1 },
end: TextPosition { row: 1, column: 1 },
content: "a👩‍🔬e\u{301}\n界x\n".into(),
},
body: "Exact text".into(),
});
review.save(&path).unwrap();
let mut loaded = Review::load(&path, &root).unwrap();
assert_eq!(loaded.comments, review.comments);
assert_eq!(loaded.comments[0].anchor.rows(), 0..=1);
let valid = serde_json::to_value(&review).unwrap();
for (field, value) in [
("start", serde_json::json!({"row": 0, "column": 4})),
("end", serde_json::json!({"row": 1, "column": 3})),
("end", serde_json::json!({"row": 2, "column": 1})),
("end", serde_json::json!({"row": 3, "column": 0})),
("end", serde_json::json!({"row": 0, "column": 0})),
("start", serde_json::json!({"row": -1, "column": 0})),
(
"start",
serde_json::json!({"row": 0, "column": 0, "extra": 0}),
),
("start_line", serde_json::json!(1)),
] {
let mut invalid = valid.clone();
invalid["comments"][0]["anchor"][field] = value;
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
assert!(Review::load(&path, &root).is_err(), "{field}");
}
let mut wrong_version = valid;
wrong_version["version"] = 1.into();
fs::write(&path, serde_json::to_vec(&wrong_version).unwrap()).unwrap();
assert!(Review::load(&path, &root).is_err());
loaded.comments[0].anchor.end = TextPosition { row: 0, column: 0 };
assert!(loaded.save(&root.join("backwards.json")).is_err());
}
} }
+265 -24
View File
@@ -4396,6 +4396,25 @@ fn draw_diff_pane(
&& comment.anchor.path == *path && comment.anchor.path == *path
&& diff.review_anchor_current(&comment.anchor) && diff.review_anchor_current(&comment.anchor)
}); });
let selected_anchor = review_comments
.clone()
.find(|comment| diff.local_review.selected == Some(comment.id))
.map(|comment| &comment.anchor);
let review_source_row = |side, display| {
diff.display_source_row(side, display).or_else(|| {
let DisplayRow::Line(index) = diff.display_rows[display] else {
return None;
};
let row = &diff.rows[index];
(unified && row.kind == RowKind::Equal)
.then_some(match side {
Focus::Before => row.before,
Focus::After => row.after,
Focus::Files => None,
})
.flatten()
})
};
for (screen, display_row, segments) in diff_screen_rows(diff, top, content.height, wrapped) { for (screen, display_row, segments) in diff_screen_rows(diff, top, content.height, wrapped) {
let row = &diff.display_rows[display_row]; let row = &diff.display_rows[display_row];
let row_area = Rect::new(content.x, content.y + screen as u16, content.width, 1); let row_area = Rect::new(content.x, content.y + screen as u16, content.width, 1);
@@ -4566,6 +4585,34 @@ fn draw_diff_pane(
}; };
paint_trailing_whitespace(frame, &text, text_area, left); paint_trailing_whitespace(frame, &text, text_area, left);
} }
if let Some(anchor) = selected_anchor {
let side = match anchor.side {
crate::review::Side::Before => Focus::Before,
crate::review::Side::After => Focus::After,
};
if let Some(source_row) = review_source_row(side, display_row)
.filter(|source_row| anchor.rows().contains(source_row))
{
let start = if source_row == anchor.start.row {
anchor.start.column
} else {
0
};
let end = if source_row == anchor.end.row {
anchor.end.column
} else {
usize::MAX
};
paint_grapheme_range(
frame,
text_area,
left,
source_text,
start..end,
Style::default().bg(Color::Magenta),
);
}
}
} }
if gutter > 0 { if gutter > 0 {
for comment in review_comments { for comment in review_comments {
@@ -4575,23 +4622,9 @@ fn draw_diff_pane(
}; };
let selected = diff.local_review.selected == Some(comment.id); let selected = diff.local_review.selected == Some(comment.id);
for (screen, display, _) in diff_screen_rows(diff, top, content.height, wrapped) { for (screen, display, _) in diff_screen_rows(diff, top, content.height, wrapped) {
let source = diff.display_source_row(side, display).or_else(|| { let source = review_source_row(side, display);
let DisplayRow::Line(index) = diff.display_rows[display] else {
return None;
};
let row = &diff.rows[index];
(unified && row.kind == RowKind::Equal)
.then_some(match side {
Focus::Before => row.before,
Focus::After => row.after,
Focus::Files => None,
})
.flatten()
});
if wrapped.is_some_and(|rows| rows[screen].position.continuation != 0) if wrapped.is_some_and(|rows| rows[screen].position.continuation != 0)
|| !source.is_some_and(|row| { || !source.is_some_and(|row| comment.anchor.rows().contains(&row))
(comment.anchor.start_line..=comment.anchor.end_line).contains(&(row + 1))
})
{ {
continue; continue;
} }
@@ -9349,8 +9382,14 @@ mod tests {
fn commented_diff() -> DiffState { fn commented_diff() -> DiffState {
use crate::review::{Anchor, Comment, Side}; use crate::review::{Anchor, Comment, Side};
let before = format!("{}\nold second\nkeep", "old ".repeat(24)); let before = format!(
let after = format!("{}\nnew second\nkeep", "new ".repeat(24)); "{}\nold e\u{301}界 second\nkeep",
"old e\u{301}界 ".repeat(12)
);
let after = format!(
"{}\nnew e\u{301}界 second\nkeep",
"new e\u{301}界 ".repeat(12)
);
let mut file = syntax_diff_file("new.txt", &before, &after); let mut file = syntax_diff_file("new.txt", &before, &after);
file.hunks = file.hunks =
crate::diff::line_hunks(&file.before, file.before_eof, &file.after, file.after_eof); crate::diff::line_hunks(&file.before, file.before_eof, &file.after, file.after_eof);
@@ -9365,8 +9404,8 @@ mod tests {
revision: "before-snapshot".into(), revision: "before-snapshot".into(),
path: "old.txt".into(), path: "old.txt".into(),
side: Side::Before, side: Side::Before,
start_line: 1, start: crate::plugin::TextPosition { row: 0, column: 0 },
end_line: 2, end: crate::plugin::TextPosition { row: 2, column: 0 },
content: before, content: before,
}, },
body: "Before range".into(), body: "Before range".into(),
@@ -9377,8 +9416,8 @@ mod tests {
revision: "after-snapshot".into(), revision: "after-snapshot".into(),
path: "new.txt".into(), path: "new.txt".into(),
side: Side::After, side: Side::After,
start_line: 1, start: crate::plugin::TextPosition { row: 0, column: 0 },
end_line: 1, end: crate::plugin::TextPosition { row: 1, column: 0 },
content: after, content: after,
}, },
body: "After line".into(), body: "After line".into(),
@@ -9388,6 +9427,155 @@ mod tests {
diff diff
} }
#[test]
fn local_review_highlights_exact_graphemes_newlines_clips_and_wrap_continuations() {
use crate::plugin::TextPosition;
use crate::review::Side;
for mode in [ViewMode::Split, ViewMode::Unified] {
for side in [Side::Before, Side::After] {
for (wrapping, horizontal) in [(false, 0), (false, 6), (true, 0)] {
for (start, end) in [((0, 4), (0, 6)), ((0, 5), (1, 5)), ((0, 84), (1, 0))] {
let mut diff = commented_diff();
diff.set_view_mode(mode);
diff.top = 0;
diff.word_wrap = wrapping;
diff.left_before = horizontal;
diff.left_after = horizontal;
let comment = diff
.local_review
.document
.comments
.iter_mut()
.find(|comment| comment.anchor.side == side)
.unwrap();
comment.anchor.start = TextPosition {
row: start.0,
column: start.1,
};
comment.anchor.end = TextPosition {
row: end.0,
column: end.1,
};
let anchor = comment.anchor.clone();
diff.local_review.selected = Some(comment.id);
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(90, 40)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
let file = &diff.comparison.as_ref().unwrap().files[0];
let wrapped = panels
.diff_wrap
.as_ref()
.map(|viewport| viewport.rows.as_slice());
let buffer = terminal.backend().buffer();
for (area, focus) in
panels.diff.as_ref().unwrap().regions.into_iter().skip(1)
{
let content = diff_inner(area);
let gutter = diff_gutter(&diff, file, content.width as usize, focus);
let width = content.width as usize - gutter;
for (screen, display, segments) in
diff_screen_rows(&diff, 0, content.height, wrapped)
{
let mut expected = vec![false; width];
if let DisplayRow::Line(aligned) = diff.display_rows[display] {
let row = &diff.rows[aligned];
let actual_side = if mode == ViewMode::Unified {
diff.display_focus(display).unwrap_or(focus)
} else {
focus
};
let anchor_side = if side == Side::Before {
Focus::Before
} else {
Focus::After
};
if actual_side == anchor_side
|| (mode == ViewMode::Unified && row.kind == RowKind::Equal)
{
let source_row = if side == Side::Before {
row.before
} else {
row.after
};
if let Some(source_row) = source_row {
let line = if side == Side::Before {
&file.before[source_row]
} else {
&file.after[source_row]
};
let segment = segments.and_then(|segments| {
segments[usize::from(actual_side == Focus::After)]
});
let left =
segment.map_or(horizontal, |segment| segment.start);
let paint_width = segment.map_or(width, |segment| {
segment_paint_width(
segment,
wrapped.unwrap()[screen].final_segments
[usize::from(actual_side == Focus::After)],
width,
)
});
for column in 0..=line.graphemes(true).count() {
let position = TextPosition {
row: source_row,
column,
};
let cell_start = display_column(line, column);
let cell_end =
if column == line.graphemes(true).count() {
cell_start + 1
} else {
display_column(line, column + 1)
};
if anchor.start <= position
&& position < anchor.end
&& cell_start >= left
&& cell_end <= left + paint_width
{
expected[cell_start - left..cell_end - left]
.fill(true);
}
}
}
}
}
let mut continuation = 0;
for (column, expected) in expected.into_iter().enumerate() {
if continuation > 0 {
continuation -= 1;
continue;
}
continuation = UnicodeWidthStr::width(
buffer[(
content.x + gutter as u16 + column as u16,
content.y + screen as u16,
)]
.symbol(),
)
.saturating_sub(1);
assert_eq!(
buffer[(
content.x + gutter as u16 + column as u16,
content.y + screen as u16,
)]
.bg
== Color::Magenta,
expected,
"{mode:?} {side:?} wrap={wrapping} left={horizontal} \
{start:?}..{end:?}, screen={screen} column={column}"
);
}
}
}
}
}
}
}
}
#[test] #[test]
fn local_review_markers_preserve_diff_signs_ranges_and_wrapped_geometry() { fn local_review_markers_preserve_diff_signs_ranges_and_wrapped_geometry() {
for mode in [ViewMode::Split, ViewMode::Unified] { for mode in [ViewMode::Split, ViewMode::Unified] {
@@ -9472,6 +9660,15 @@ mod tests {
.all(|cell| !matches!(cell.symbol(), "●" | "○")), .all(|cell| !matches!(cell.symbol(), "●" | "○")),
"{mode:?} {mismatch}" "{mode:?} {mismatch}"
); );
assert!(
terminal
.backend()
.buffer()
.content
.iter()
.all(|cell| cell.bg != Color::Magenta),
"{mode:?} {mismatch}"
);
} }
} }
} }
@@ -9486,8 +9683,8 @@ mod tests {
.comments .comments
.retain(|comment| comment.anchor.side == side); .retain(|comment| comment.anchor.side == side);
let comment = &mut diff.local_review.document.comments[0]; let comment = &mut diff.local_review.document.comments[0];
comment.anchor.start_line = 3; comment.anchor.start = crate::plugin::TextPosition { row: 2, column: 1 };
comment.anchor.end_line = 3; comment.anchor.end = crate::plugin::TextPosition { row: 2, column: 3 };
diff.local_review.selected = Some(comment.id); diff.local_review.selected = Some(comment.id);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 20)).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 20)).unwrap();
render_diff(&mut terminal, &diff, &mut WheelPanels::default()); render_diff(&mut terminal, &diff, &mut WheelPanels::default());
@@ -9496,6 +9693,20 @@ mod tests {
"{side:?}: {}", "{side:?}: {}",
screen_text(&terminal) screen_text(&terminal)
); );
let buffer = terminal.backend().buffer();
let marker = buffer
.content
.iter()
.position(|cell| cell.symbol() == "●")
.unwrap();
for offset in 1..=4 {
assert_eq!(
buffer.content[marker + offset].bg == Color::Magenta,
(2..=3).contains(&offset),
"{side:?}: source column {}",
offset - 1
);
}
} }
} }
@@ -14096,6 +14307,36 @@ end
} }
} }
#[test]
fn native_visual_selection_overrides_selected_review_range_background() {
for unified in [false, true] {
let mut editor = readonly_diff_editor(syntax_diff_file(
"file.txt",
"old e\u{301}界 text",
"new e\u{301}界 text",
));
if unified {
editor.execute("diff.view.toggle");
}
editor.handle(Input::Char('\t'));
editor.execute("diff.comment");
editor.handle(Input::Escape);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 20)).unwrap();
let mut views = BufferViewports::default();
let source = render_native_diff(&mut terminal, &mut views, &mut editor);
assert_eq!(
terminal.backend().buffer()[(source.x, source.y)].bg,
Color::Magenta
);
editor.handle(Input::Char('v'));
editor.handle(Input::Right);
render_native_diff(&mut terminal, &mut views, &mut editor);
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(source.x, source.y)].bg, Color::Blue);
assert!(buffer.content.iter().any(|cell| cell.bg == Color::Magenta));
}
}
#[test] #[test]
fn review_editor_floats_over_the_diff_without_changing_workspace_layout() { fn review_editor_floats_over_the_diff_without_changing_workspace_layout() {
for unified in [false, true] { for unified in [false, true] {