Compare commits

...

8 Commits

Author SHA1 Message Date
Valdemar Lorenzen 31203889bb fix: preserve review comments across unrelated edits 2026-10-20 16:28:53 +02:00
Valdemar Lorenzen 2727425518 fix: pad LSP indicators in both bars 2026-10-20 16:28:53 +02:00
Valdemar Lorenzen 33a8c423ac fix: clarify review IDs and CLI guidance 2026-10-20 16:28:53 +02:00
Valdemar Lorenzen 759947736e improve: show whole-diff totals alongside file stats 2026-10-20 16:28:53 +02:00
Valdemar Lorenzen a5a246db77 feat: copy diff review comments to configured clipboard 2026-10-20 16:28:53 +02:00
Valdemar Lorenzen eae3bdba16 fix: clip inline blame instead of hiding long metadata
The full-label width check hid current-line attribution when only part of the commit metadata fit. Render into the remaining cells without covering source text; preserve offscreen and no-space guards.
2026-10-20 16:28:53 +02:00
Valdemar Lorenzen 4fb1be7972 fix: show revision-aware blame in diff buffers
Diff and snapshot sources were excluded before scheduling, and the separate diff renderer never painted attribution. Capture per-side Git provenance, preserve private object stores for historical tabs, and reuse the current-line worker and cache.
2026-10-20 16:28:53 +02:00
Valdemar Lorenzen 8f8d96ead9 fix: key review checkmarks by file snapshots instead of revision pairs 2026-10-20 16:28:53 +02:00
26 changed files with 2108 additions and 363 deletions
+2
View File
@@ -25,6 +25,8 @@ vamp --review delete 1 3 --repo . # Delete comment 3 from review 1
The review CLI discovers the Git/Jujutsu checkout from `--repo` (default: current directory). It uses the same default configuration, `--config PATH`, `--no-config`, and `review_directory` as the editor, but starts no editor workers or terminal UI. Missing storage lists no reviews without creating a database; incompatible storage is rejected and preserved. The review CLI discovers the Git/Jujutsu checkout from `--repo` (default: current directory). It uses the same default configuration, `--config PATH`, `--no-config`, and `review_directory` as the editor, but starts no editor workers or terminal UI. Missing storage lists no reviews without creating a database; incompatible storage is rejected and preserved.
Use the persistent **Review ID** printed by `list`, not a list position. IDs are shared across repositories; listing filters them to the selected checkout, so its first review need not be ID 1. Human-readable listings include a command to show each review. The internal save revision is omitted from text but remains in JSON; it is not a Git/Jujutsu revision. `show all` is not supported: use `show ID --all` to include resolved comments in that review.
`show` includes unresolved comments by default; `--all` includes resolved comments too. Human-readable output is the default. JSON includes the canonical repository root, storage review ID/revision, summary/verdict, total/resolved/unresolved counts, and comments with exact zero-based, end-exclusive grapheme anchors and selected source excerpts—not whole source files. `list --json` returns the repository and review metadata/counts without comments. Explicit editor JSON exports still contain complete anchor source text. `show` includes unresolved comments by default; `--all` includes resolved comments too. Human-readable output is the default. JSON includes the canonical repository root, storage review ID/revision, summary/verdict, total/resolved/unresolved counts, and comments with exact zero-based, end-exclusive grapheme anchors and selected source excerpts—not whole source files. `list --json` returns the repository and review metadata/counts without comments. Explicit editor JSON exports still contain complete anchor source text.
`list` and `show` are read-only. `delete REVIEW_ID COMMENT_ID` immediately commits deletion of one resolved or unresolved comment, then prints the updated review with its remaining unresolved comments (`--json` is supported). Both IDs are required; missing reviews/comments and repository mismatches fail without changing stored reviews or creating missing storage. Deleting the last comment keeps the review, summary, and verdict. Concurrent saves use the same revision-conflict protection as the editor; an already-open editor must reload before saving after a CLI deletion. Existing JSON exports are not modified. `list` and `show` are read-only. `delete REVIEW_ID COMMENT_ID` immediately commits deletion of one resolved or unresolved comment, then prints the updated review with its remaining unresolved comments (`--json` is supported). Both IDs are required; missing reviews/comments and repository mismatches fail without changing stored reviews or creating missing storage. Deleting the last comment keeps the review, summary, and verdict. Concurrent saves use the same revision-conflict protection as the editor; an already-open editor must reload before saving after a CLI deletion. Existing JSON exports are not modified.
+2 -2
View File
@@ -12,7 +12,7 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
- `chrome`: owns validated top/bottom bar anchor groups, segment title templates and padding/edge options, builtin defaults, named-value validation, and typed command/search popup anchors. Both bars accept the same segments and layout options. It has no terminal geometry or render callbacks. - `chrome`: owns validated top/bottom bar anchor groups, segment title templates and padding/edge options, builtin defaults, named-value validation, and typed command/search popup anchors. Both bars accept the same segments and layout options. It has no terminal geometry or render callbacks.
- `battery`: reads native charge off the input thread, sampling immediately and every 30 seconds through an interruptible worker. macOS CoreFoundation references are type-checked and owned Copy/Create values released; Linux reads system battery sysfs and Windows reads native power status. A one-slot atomic mailbox retains the latest sample; absent/invalid readings clear cached presentation instead of inventing a percentage. Session starts collection only while an unoverridden battery segment is configured. - `battery`: reads native charge off the input thread, sampling immediately and every 30 seconds through an interruptible worker. macOS CoreFoundation references are type-checked and owned Copy/Create values released; Linux reads system battery sysfs and Windows reads native power status. A one-slot atomic mailbox retains the latest sample; absent/invalid readings clear cached presentation instead of inventing a percentage. Session starts collection only while an unoverridden battery segment is configured.
- `shell`: runs explicit `:!` commands off the input thread with inherited environment, closed stdin, and bounded stdout/stderr capture. Each command has a private Unix process group, killed and reaped on worker drop or shell completion. Session owns concurrent workers and combines their delayed spinners in one shared status segment; Editor owns the read-only output popup, queued completed results, and modal scrolling/dismissal state. No repository-command environment overrides or timeouts apply. - `shell`: runs explicit `:!` commands off the input thread with inherited environment, closed stdin, and bounded stdout/stderr capture. Each command has a private Unix process group, killed and reaped on worker drop or shell completion. Session owns concurrent workers and combines their delayed spinners in one shared status segment; Editor owns the read-only output popup, queued completed results, and modal scrolling/dismissal state. No repository-command environment overrides or timeouts apply.
- `blame`: owns local current-line Git attribution against the supplied in-memory text and a cancellable background worker. Git-backed jj uses its working-copy parent's commit through an isolated temporary Git store; active refs/checkouts remain untouched. - `blame`: owns current-line Git attribution against supplied in-memory text and a cancellable background worker. Historical buffers capture their exact Git store, commit, and side-specific path; private stores remain alive while snapshots or requests reference them. Git-backed jj uses an isolated temporary Git store; active refs/checkouts remain untouched.
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations. - `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
Virtual diff leaves retain their original catalog input index for caller-owned annotations. Normal scanned leaves accept optional repository status and old-path metadata without filtering unchanged files; missing tracked deletions add virtual leaves and required directories. Metadata-only updates preserve query, selection, expansion, and view without rebuilding the catalog. Virtual diff leaves retain their original catalog input index for caller-owned annotations. Normal scanned leaves accept optional repository status and old-path metadata without filtering unchanged files; missing tracked deletions add virtual leaves and required directories. Metadata-only updates preserve query, selection, expansion, and view without rebuilding the catalog.
- `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.
@@ -58,7 +58,7 @@ Frontends borrow `top_bar_config()` and `bottom_bar_config()` for validated orde
Frontends borrow `popup_config()` for independently configured command/search anchors. The TUI resolves each anchor against the chrome-free workspace using the existing bounded popup size; startup configuration does not alter buffer text, modal input, or notification positioning. Frontends borrow `popup_config()` for independently configured command/search anchors. The TUI resolves each anchor against the chrome-free workspace using the existing bounded popup size; startup configuration does not alter buffer text, modal input, or notification positioning.
Frontends borrow `lsp_label()` / `lsp_names()` for the focused buffer's cached ready-server metadata. Initialization-only worker events trigger status redraws independently of diagnostics; session synchronization clears or replaces names when focus changes or a server retires. `notification_visible()` gates persistent operation/diagnostic popups independently of stored message text, so Escape dismissal neither alters text nor collapses carets. Display-only home abbreviation does not change document identity. Frontends borrow `lsp_label()` / `lsp_names()` for the focused buffer's cached ready-server metadata. Initialization-only worker events trigger status redraws independently of diagnostics; session synchronization clears or replaces names when focus changes or a server retires. `notification_visible()` gates persistent operation/diagnostic popups independently of stored message text, so Escape dismissal neither alters text nor collapses carets. Display-only home abbreviation does not change document identity.
Current-line blame is enabled by default. Session loads whole-file incremental attribution into one active-buffer cache keyed by buffer/text/save revision, disk stamp, and path; cursor, tab, and pane movements within that source reuse it without copying file text or issuing repository commands. Cold results select the current row when they arrive. Edits, saves, source changes, disk refreshes, focus regain, and disabling invalidate attribution; stale results cannot replace a newer source. Metadata is shared per commit with `Arc`, and same-commit navigation reuses the sanitized label. Rendering uses only spare cells after the current logical line's final visible continuation and hides it when the primary caret is offscreen. Disabling drops the worker; comparisons and immutable buffers never schedule live blame. Current-line blame is enabled by default. Session loads whole-file incremental attribution into one active-buffer cache keyed by buffer/text/save revision, disk stamp, path, and optional historical source; cursor, tab, and pane movements within that source reuse it without copying file text or issuing repository commands. Cold results select the current row when they arrive. Edits, saves, source changes, disk refreshes, focus regain, and disabling invalidate attribution; stale results cannot replace a newer source. Comparison provenance follows accepted snapshot/checkout/bookmark refreshes and is retained by immutable buffers independently of their original comparison. Historical queries use a private HEAD and object alternates, never live-file existence or the current checkout baseline. Metadata is shared per commit with `Arc`, and same-commit navigation reuses the sanitized label. Ordinary and split/unified diff rendering use only spare cells after the current logical line's final visible continuation and hide the label when the primary caret is offscreen. Disabling drops the worker.
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion, optional repository status and old path). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors and sanitized rename labels, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned. Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion, optional repository status and old path). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors and sanitized rename labels, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds. Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
+9 -7
View File
@@ -121,7 +121,7 @@ The renderer inserts one unstyled blank column between visible items in each gro
Use the two setters independently; a combined `config.bar = { ... }` assignment is not supported. Use the two setters independently; a combined `config.bar = { ... }` assignment is not supported.
Defaults put `left: [:tabs]` and `right: [:shell, :battery]` in the top bar; the bottom has `left: [:mode, :pending, :buffer, :diagnostics, :path, :selections, :count]` and `right: [:language, :lsp, :position]`; other groups are empty. Set the bars independently, as in this swapped layout with status information above and tabs plus live battery charge below: Defaults put `left: [:tabs]` and `right: [:diff, :shell, :battery]` in the top bar; the bottom has `left: [:mode, :pending, :buffer, :diagnostics, :path, :selections, :count]` and `right: [:language, :lsp, :position]`; other groups are empty. Set the bars independently, as in this swapped layout with status information above and tabs plus diff totals, live battery charge, and shell status below:
```ruby ```ruby
Vamp.configure do |config| Vamp.configure do |config|
@@ -132,9 +132,9 @@ Vamp.configure do |config|
position: { padding: 1, leading_edge: "░▓", trailing_edge: "" } position: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
} }
config.bar.bottom = { config.bar.bottom = {
left: [:tabs], center: [], right: [:shell, :battery], left: [:tabs], center: [], right: [:diff, :shell, :battery],
tabs: { tabs: {
title: "{index} {view}{name}{modified}", padding: 1, title: "{index} {view}{name}{changes}{modified}", padding: 1,
leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: "" leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: ""
}, },
battery: { padding: 1, leading_edge: "░▓", trailing_edge: "" } battery: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
@@ -142,11 +142,11 @@ Vamp.configure do |config|
end end
``` ```
Builtins are `:tabs`, `:battery`, `:shell`, `:mode`, `:path`, `:position`, `:language`, `:lsp`, `:diagnostics`, `:selections`, `:count`, `:pending`, and `:buffer`, in either bar. They provide tab titles, battery charge, running shell command/spinner, mode, file path/dirty state, caret position, detected language, ready LSP server names, diagnostics, selection count, numeric prefix, pending key sequence, and buffer identity/count. Builtins are `:tabs`, `:diff`, `:battery`, `:shell`, `:mode`, `:path`, `:position`, `:language`, `:lsp`, `:diagnostics`, `:selections`, `:count`, `:pending`, and `:buffer`, in either bar. They provide tab titles, whole-comparison line counts, battery charge, running shell command/spinner, mode, file path/dirty state, caret position, detected language, ready LSP server names, diagnostics, selection count, numeric prefix, pending key sequence, and buffer identity/count.
A bar with no visible content consumes no row. For example, `config.bar.top = { left: [], center: [], right: [] }` removes the top bar. A bar whose only visible content is `:tabs` is hidden with a single tab, whether above or below the workspace. Narrow layouts first omit inactive tabs, then optional segments from group ends to avoid overlap; the active tab drops decorations before its title is clipped. Commands and buffer searches use floating input; operation feedback uses a bottom-right popup. Neither reserves a footer row. A bar with no visible content consumes no row. For example, `config.bar.top = { left: [], center: [], right: [] }` removes the top bar. A bar whose only visible content is `:tabs` is hidden with a single tab, whether above or below the workspace. Narrow layouts first omit inactive tabs, then optional segments from group ends to avoid overlap; the active tab drops decorations before its title is clipped. Commands and buffer searches use floating input; operation feedback uses a bottom-right popup. Neither reserves a footer row.
Each segment's `title` is a literal template supporting only `{index}`, `{view}`, `{name}`, `{path}`, and `{modified}`: the one-based tab index, comparison-view marker, filename, file path, and dirty marker (`" [+]"` when dirty, `""` when clean). `{view}` expands to `"Diff · +N -M · "` for a loaded comparison file, counting added and removed lines; revision selectors and files still loading use `"Diff · "`, and ordinary tabs use `""`. Counts follow the selected file and live edits, including inactive tabs. Tabs use each tab's view and buffer metadata; other segments use the active context. Tabs default to `"{index} {view}{name}{modified}"`; custom templates without `{view}` retain their output. An empty title on another segment uses its builtin content or named label value. Templates are not Ruby code and never invoke a render callback. Each segment's `title` is a literal template supporting only `{index}`, `{view}`, `{name}`, `{path}`, `{changes}`, and `{modified}`: the one-based tab index, comparison-view marker, filename, file path, selected-file line counts, and dirty marker (`" [+]"` when dirty, `""` when clean). `{view}` expands to `"Diff · "` for comparisons and revision selectors, or `""` for ordinary tabs. `{changes}` expands to `" +N -M"` for a loaded comparison file and `""` otherwise. Counts follow the selected file and live edits, including inactive tabs. Tabs use each tab's view and buffer metadata; other segments use the active context. Tabs default to `"{index} {view}{name}{changes}{modified}"`; custom templates must include `{changes}` to display per-file counts. An empty title on another segment uses its builtin content or named label value. Templates are not Ruby code and never invoke a render callback.
`:path` and `{path}` abbreviate the actual home-directory prefix to `~/` (`~` for the home directory itself). This is display-only: save paths, buffer identity, and LSP file URIs retain the original path. Sibling directories with similar names are not abbreviated. `:path` and `{path}` abbreviate the actual home-directory prefix to `~/` (`~` for the home directory itself). This is display-only: save paths, buffer identity, and LSP file URIs retain the original path. Sibling directories with similar names are not abbreviated.
@@ -154,7 +154,7 @@ Each segment's `title` is a literal template supporting only `{index}`, `{view}`
`margin` adds unstyled space outside both edges of a segment, unlike the styled internal `padding`. `leading_margin` and `trailing_margin` override their respective sides. All accept integers from `0` through `64`; all margins default to zero. Explicit margins are additional to the renderer's one-column separator, so leave them at zero for normal inter-item spacing. Margins count toward anchor placement and collision handling; empty segments consume no margin, and margins yield along with padding and edges when only clipped content fits. `margin` adds unstyled space outside both edges of a segment, unlike the styled internal `padding`. `leading_margin` and `trailing_margin` override their respective sides. All accept integers from `0` through `64`; all margins default to zero. Explicit margins are additional to the renderer's one-column separator, so leave them at zero for normal inter-item spacing. Margins count toward anchor placement and collision handling; empty segments consume no margin, and margins yield along with padding and edges when only clipped content fits.
`padding` adds spaces inside the segment's styling, on both sides of its title, and accepts integers from `0` through `64`. It defaults to `0` for plain-text and custom segments and `1` for tabs, mode, position, battery, and shell status. Internal padding is separate from the inter-item separator; explicit padding can deliberately add more whitespace. `leading_edge` and `trailing_edge` are separate styled glyph strings, not padding; `""` removes an edge. Tabs and shell status default to `"░▓"` / `"▓░"` edges; mode has only the trailing `"▓░"` edge, position and battery only the leading `"░▓"` edge, and other segments have none. `first_leading_edge` optionally overrides the first occurrence's leading glyphs: for tabs this is the actual first tab, not the first tab remaining after clipping; other segments have one occurrence. Tabs default this override to `""`, suppressing the first tab's left fade. Unicode widths are measured in terminal cells. Edge strings must be single-line and contain no control characters. Unknown style keys, invalid templates, and wrong types reject configuration. `padding` adds spaces inside the segment's styling, on both sides of its title, and accepts integers from `0` through `64`. It defaults to `0` for plain-text and custom segments and `1` for tabs, mode, position, battery, diff totals, shell status, and LSP indicators. Internal padding is separate from the inter-item separator; explicit padding can deliberately add more whitespace. `leading_edge` and `trailing_edge` are separate styled glyph strings, not padding; `""` removes an edge. Tabs, diff totals, and shell status default to `"░▓"` / `"▓░"` edges; mode has only the trailing `"▓░"` edge, position and battery only the leading `"░▓"` edge, and other segments have none. `first_leading_edge` optionally overrides the first occurrence's leading glyphs: for tabs this is the actual first tab, not the first tab remaining after clipping; other segments have one occurrence. Tabs default this override to `""`, suppressing the first tab's left fade. Unicode widths are measured in terminal cells. Edge strings must be single-line and contain no control characters. Unknown style keys, invalid templates, and wrong types reject configuration.
Any other segment name is a custom named value, such as `:build_status`. Set initial strings with `config.labels`; an empty value hides the custom segment, including its title override, margins, padding, and edges. Names match `[A-Za-z_][A-Za-z0-9_]*`. Labels are plain text: control characters are replaced rather than interpreted as terminal escapes. The same named value can appear in either bar. Any other segment name is a custom named value, such as `:build_status`. Set initial strings with `config.labels`; an empty value hides the custom segment, including its title override, margins, padding, and edges. Names match `[A-Za-z_][A-Za-z0-9_]*`. Labels are plain text: control characters are replaced rather than interpreted as terminal escapes. The same named value can appear in either bar.
@@ -164,6 +164,8 @@ Move `:battery` to any anchor group or remove it from both bars to stop battery
`:shell` is one shared segment containing the abbreviations and animated spinners for all running `:!COMMAND` processes. Each entry appears only after its command has run for 200 ms, avoiding flashes for short commands. Completed output is shown immediately or queued behind the current result window. The segment hides when idle. It defaults immediately left of `:battery`, with its own styled edges and the standard one-column separator; move it to any anchor group or omit it without affecting command execution. A nonempty label named `shell` overrides the builtin display; clearing it restores the running indicators. `:shell` is one shared segment containing the abbreviations and animated spinners for all running `:!COMMAND` processes. Each entry appears only after its command has run for 200 ms, avoiding flashes for short commands. Completed output is shown immediately or queued behind the current result window. The segment hides when idle. It defaults immediately left of `:battery`, with its own styled edges and the standard one-column separator; move it to any anchor group or omit it without affecting command execution. A nonempty label named `shell` overrides the builtin display; clearing it restores the running indicators.
`:diff` displays `Diff +N -M` for the active comparison, including unopened and filtered-out files, and hides in revision selectors and ordinary tabs. Live edits and undo/redo update totals. An empty comparison shows `Diff +0 -0`; incomplete statistics show `Diff …`, never a partial total. It defaults left of shell status and battery; move it to either bar or omit it like any builtin. See [counting semantics](editing.md#repository-diff).
### Command and search popup anchors ### Command and search popup anchors
Configure the two input windows independently: Configure the two input windows independently:
@@ -240,7 +242,7 @@ Normal and Visual Ctrl+u/Ctrl+d invoke `config.core.scroll_step_up` / `config.co
| Normal | `follow_link` | | Normal | `follow_link` |
| Normal | `diff`, `diff.mode.toggle`, `diff.view.toggle`, `diff.orientation.toggle`, `diff.review.toggle` | | Normal | `diff`, `diff.mode.toggle`, `diff.view.toggle`, `diff.orientation.toggle`, `diff.review.toggle` |
| Normal, Visual | `diff.comment` | | Normal, Visual | `diff.comment` |
| Normal | `diff.review.edit`, `diff.review.delete`, `diff.review.resolve`, `diff.review.unresolve`, `diff.review.next`, `diff.review.previous`, `diff.review.overview`, `diff.review.summary`, `diff.review.verdict`, `diff.review.save`, `diff.review.open`, `diff.review.new` | | Normal | `diff.review.edit`, `diff.review.delete`, `diff.review.resolve`, `diff.review.unresolve`, `diff.review.next`, `diff.review.previous`, `diff.review.overview`, `diff.review.copy`, `diff.review.summary`, `diff.review.verdict`, `diff.review.save`, `diff.review.open`, `diff.review.new` |
| Normal, Insert, Visual, Command, Search | `diff.close` | | Normal, Insert, Visual, Command, Search | `diff.close` |
| Normal | `resolve`, `resolve.next`, `resolve.previous`, `resolve.use_source`, `resolve.source`, `resolve.base`, `resolve.next_file`, `resolve.previous_file`, `resolve.files`, `resolve.draft`, `resolve.history`, `resolve.view.toggle`, `resolve.close` | | Normal | `resolve`, `resolve.next`, `resolve.previous`, `resolve.use_source`, `resolve.source`, `resolve.base`, `resolve.next_file`, `resolve.previous_file`, `resolve.files`, `resolve.draft`, `resolve.history`, `resolve.view.toggle`, `resolve.close` |
| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` | | Normal, Insert, Visual, Command, Search | `word_wrap.toggle` |
+8
View File
@@ -68,8 +68,12 @@ Clipboard checks use isolated providers rather than reading or overwriting the d
Diff checks cover staged category/filter navigation, stale request cancellation, row alignment, read-only buffer isolation, and rendered panes. Repository integration fixtures exercise Git and Git-backed jj, advertised but unfetched remote branches/bookmarks/tags, imported historical commit/change IDs, conflicts, unusual paths, binary/mode/link/submodule changes, and unchanged active metadata. Remote fixtures are local bare copies; they never push. Git fixture identities and commit dates are fixed. Diff checks cover staged category/filter navigation, stale request cancellation, row alignment, read-only buffer isolation, and rendered panes. Repository integration fixtures exercise Git and Git-backed jj, advertised but unfetched remote branches/bookmarks/tags, imported historical commit/change IDs, conflicts, unusual paths, binary/mode/link/submodule changes, and unchanged active metadata. Remote fixtures are local bare copies; they never push. Git fixture identities and commit dates are fixed.
Diff-stat checks cover unopened frozen files, additions/deletions/renames, binary/mode changes, CRLF/EOF, refresh, oversized-file laziness, filtering, live edits/undo, and inactive tabs. Live PTY smoke opens a two-file Git Working comparison, observes selected-file `+2 -1` alongside whole-diff `+2 -2` before opening the second file, edits and undoes text, switches to an ordinary tab, and exits with source bytes and HEAD unchanged.
Local review checks cover native comment/summary composition and undo, Visual ranges on either side, verdicts, JSON save/reopen, external-write refusal, hidden drafts and teardown guards, immutable Working identities in Git/jj, renamed paths, live EOF, and stale anchors. Render checks cover selected/range markers, split/unified context, wrapped continuations, and narrow gutters. Live PTY smoke creates and saves a comment/summary/verdict, closes and reopens the review, edits the reopened comment, changes the reviewed source, and observes stale status without altering the saved anchor. Fixtures are local repositories; no forge writes. Local review checks cover native comment/summary composition and undo, Visual ranges on either side, verdicts, JSON save/reopen, external-write refusal, hidden drafts and teardown guards, immutable Working identities in Git/jj, renamed paths, live EOF, and stale anchors. Render checks cover selected/range markers, split/unified context, wrapped continuations, and narrow gutters. Live PTY smoke creates and saves a comment/summary/verdict, closes and reopens the review, edits the reopened comment, changes the reviewed source, and observes stale status without altering the saved anchor. Fixtures are local repositories; no forge writes.
Review clipboard checks cover System/Separate routing with isolated providers, exact Unicode excerpts, resolved/stale comments across files, retained unsaved drafts, unchanged storage revisions, and empty/invalid/failed-copy preservation. Live PTY smoke imports a review, copies it, pastes into an empty buffer, and verifies the saved bytes exactly match CLI `show --all`; default CLI output still excludes resolved comments.
SQLite review checks cover exact anchors across restarts and revision changes, repository isolation, independent connections and optimistic conflicts, locked/unwritable stores, incompatible schema preservation, relational constraints, full unsigned comment IDs, stable comment order, bounded/corrupt rows, rollback after partial comment insertion, private file permissions, and XDG/config path resolution. Schema 1 document storage is rejected without modification. Editor checks require explicit writes for comment/summary text, discard unsaved text on dismissal without retaining discarded undo history, prevent metadata autosaves from committing hidden drafts, persist verdicts/deletions, restore a sole review, choose among multiple reviews, and keep exports independent from autosaves. Test fixtures use isolated review directories. Live PTY smoke verifies that Escape leaves no new stored comment, `:w` persists exact text, and pane close discards a later edit; reopening and writing again preserves the saved body. SQLite review checks cover exact anchors across restarts and revision changes, repository isolation, independent connections and optimistic conflicts, locked/unwritable stores, incompatible schema preservation, relational constraints, full unsigned comment IDs, stable comment order, bounded/corrupt rows, rollback after partial comment insertion, private file permissions, and XDG/config path resolution. Schema 1 document storage is rejected without modification. Editor checks require explicit writes for comment/summary text, discard unsaved text on dismissal without retaining discarded undo history, prevent metadata autosaves from committing hidden drafts, persist verdicts/deletions, restore a sole review, choose among multiple reviews, and keep exports independent from autosaves. Test fixtures use isolated review directories. Live PTY smoke verifies that Escape leaves no new stored comment, `:w` persists exact text, and pane close discards a later edit; reopening and writing again preserves the saved body.
Manual-resolution checks cover immediate persistence, reopen/export/import, independent stale status, conflict-retained drafts, and incompatible version preservation. CLI checks cover filename-safe dispatch, Unicode excerpts, unresolved filtering/counts, absent storage, read-only access and permissions, symlink rejection, and incompatible schema preservation. Manual-resolution checks cover immediate persistence, reopen/export/import, independent stale status, conflict-retained drafts, and incompatible version preservation. CLI checks cover filename-safe dispatch, Unicode excerpts, unresolved filtering/counts, absent storage, read-only access and permissions, symlink rejection, and incompatible schema preservation.
@@ -77,6 +81,7 @@ Live PTY/CLI smoke composes and explicitly saves a comment, resolves it in the e
CLI deletion checks exercise explicit review/comment IDs, resolved-comment deletion, repository isolation, preserved summaries/verdicts/other comments, last-comment deletion, missing targets/storage, and stale-save conflicts through the executable. CLI smoke verifies persisted deletion and rejects a repeated deletion without advancing the review revision. CLI deletion checks exercise explicit review/comment IDs, resolved-comment deletion, repository isolation, preserved summaries/verdicts/other comments, last-comment deletion, missing targets/storage, and stale-save conflicts through the executable. CLI smoke verifies persisted deletion and rejects a repeated deletion without advancing the review revision.
Relational-storage smoke inspects typed review/comment columns, round-trips exact Unicode anchors through the store and JSON export/import, deletes through the real CLI, and confirms the stored review and existing export remain intact. Relational-storage smoke inspects typed review/comment columns, round-trips exact Unicode anchors through the store and JSON export/import, deletes through the real CLI, and confirms the stored review and existing export remain intact.
Reviewed-file checks cover SQLite/JSON round-trips, unmarking, exact revision pairs and rename paths across catalog reordering, lazy loading, live text/final-newline mismatch, edit-triggered invalidation, selected-review isolation, hidden-draft isolation, conflicts, and transactional rollback. Live PTY smoke marks a file in a real Git comparison, verifies the SQLite row, restarts the editor, observes the restored checkmark, unmarks it, and verifies row removal with unchanged source bytes. Reviewed-file checks cover SQLite/JSON round-trips, unmarking, exact revision pairs and rename paths across catalog reordering, lazy loading, live text/final-newline mismatch, edit-triggered invalidation, selected-review isolation, hidden-draft isolation, conflicts, and transactional rollback. Live PTY smoke marks a file in a real Git comparison, verifies the SQLite row, restarts the editor, observes the restored checkmark, unmarks it, and verifies row removal with unchanged source bytes.
Per-file persistence also exercises a real Git Working comparison: mark two lazy files, save/reload the review, edit one file, refresh the snapshot, and retain only the unchanged file's mark through detail loading.
Conflicted-revision regressions preserve unchanged symlinks, defer oversized unselected blobs until file selection, compare selected conflict text with jj's projection, and exclude unchanged conflicts when comparing later revisions. Live terminal smoke covers local bookmark selection and opening an actual conflicted file without changing the checkout. Conflicted-revision regressions preserve unchanged symlinks, defer oversized unselected blobs until file selection, compare selected conflict text with jj's projection, and exclude unchanged conflicts when comparing later revisions. Live terminal smoke covers local bookmark selection and opening an actual conflicted file without changing the checkout.
@@ -89,6 +94,9 @@ Shared-workspace parity checks compare ordinary and resolver diff geometry/conte
Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart. Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart.
Blame checks cover selected historical commits rather than live HEAD, renamed/deleted paths, unsafe/nonregular sources, Git-backed non-colocated jj, and privately fetched objects surviving comparison/repository teardown until the final snapshot releases them. Render/session regressions cover both split/unified sides, stale side results, wrapping, narrow panes, detached historical tabs, and Working attribution. Live PTY smoke opens both historical sides and unified view after deleting the file from the checkout, observes the correct commit labels, opens a historical tab, and exits with unchanged HEAD and tracked state.
Partial-label checks navigate short and longer source lines with wide/combining author names and long commit summaries in ordinary, split, and unified views. Live PTY smoke repeats three-line navigation in split/unified views with a long summary, observing attribution on every source line with unchanged source bytes and HEAD.
Normal-picker checks cover staged/disk/unsaved status, binary and mode changes, exact and ambiguous renames, deleted paths, unborn repositories, aliases and unusual filenames, ignores and safe roots, linked worktrees, and read-only colocated/non-colocated/conflicted jj parents. Catalog/render checks retain unchanged rows, query/selection/expansion and Tree/List navigation, clear stale metadata, restore recreated files, and sanitize rename labels. Live PTY smoke checks A/M/D/R colors in floating and docked pickers with the line gutter disabled, Git HEAD/jj parent refresh, and unchanged jj operation state during polling. Normal-picker checks cover staged/disk/unsaved status, binary and mode changes, exact and ambiguous renames, deleted paths, unborn repositories, aliases and unusual filenames, ignores and safe roots, linked worktrees, and read-only colocated/non-colocated/conflicted jj parents. Catalog/render checks retain unchanged rows, query/selection/expansion and Tree/List navigation, clear stale metadata, restore recreated files, and sanitize rename labels. Live PTY smoke checks A/M/D/R colors in floating and docked pickers with the line gutter disabled, Git HEAD/jj parent refresh, and unchanged jj operation state during polling.
Diff LSP integration checks reuse the real framed-protocol subprocess with test-controlled response/publication gates and local Git comparisons. Cover both immutable historical sides and writable Working After, snapshot text/URI isolation, Unicode UTF-16 requests/targets/diagnostics, normal overlong-column clamping, hover, completion/undo, and read-only editing guards. Definition routing must distinguish ranges overlapping actual changed rows from wholly unchanged/context/omitted targets, use full location-link target ranges with exact selection reveal and exclusive endpoints, route canonical aliases and old/new rename paths, load other changed files on demand, handle added/deleted files, preserve original tabs/panes/excerpts and shared dirty targets, retain snapshot identity in ordinary tabs, and fail atomically for missing/non-UTF-8 files, invalid ranges, and unsupported URIs. Delayed/superseded replies must remain stale across caret moves, edit/undo, side/file/pane/tab changes, and comparison closure/replacement; publications for disposed snapshot identities must not reach their replacements. Private editor checks gate pending repository-load cancellation and exact-row reveal without timing assumptions. Before marking the TODO complete, run native integration/unit checks and an actual terminal smoke of focused diff LSP actions, changed-line routing, external new tabs, and immutable/editable side behavior. Diff LSP integration checks reuse the real framed-protocol subprocess with test-controlled response/publication gates and local Git comparisons. Cover both immutable historical sides and writable Working After, snapshot text/URI isolation, Unicode UTF-16 requests/targets/diagnostics, normal overlong-column clamping, hover, completion/undo, and read-only editing guards. Definition routing must distinguish ranges overlapping actual changed rows from wholly unchanged/context/omitted targets, use full location-link target ranges with exact selection reveal and exclusive endpoints, route canonical aliases and old/new rename paths, load other changed files on demand, handle added/deleted files, preserve original tabs/panes/excerpts and shared dirty targets, retain snapshot identity in ordinary tabs, and fail atomically for missing/non-UTF-8 files, invalid ranges, and unsupported URIs. Delayed/superseded replies must remain stale across caret moves, edit/undo, side/file/pane/tab changes, and comparison closure/replacement; publications for disposed snapshot identities must not reach their replacements. Private editor checks gate pending repository-load cancellation and exact-row reveal without timing assumptions. Before marking the TODO complete, run native integration/unit checks and an actual terminal smoke of focused diff LSP actions, changed-line routing, external new tabs, and immutable/editable side behavior.
+11 -6
View File
@@ -258,9 +258,9 @@ Text wraps at word boundaries where possible, with grapheme-safe breaks for long
## Current-line Git blame ## Current-line Git blame
Inline blame is enabled by default in ordinary live file buffers. The primary caret's line shows its author, commit summary, and short commit ID after the document text; changed or newly inserted tracked-file lines show **Uncommitted**, including unsaved edits. Git-backed jj repositories use the working-copy revision's single parent, not a potentially stale Git HEAD. Merge working copies with multiple parents omit attribution. Inline blame is enabled by default in live file buffers and on the focused side of split/unified diffs. The primary caret's line shows its author, commit summary, and short commit ID after the document text; changed or newly inserted tracked-file lines show **Uncommitted**, including unsaved edits. Live Git-backed jj buffers use the working-copy revision's single parent, not a potentially stale Git HEAD. Merge working copies with multiple parents omit live attribution.
Loading happens in the background without network access or changing repository refs/checkouts. Attribution is loaded once for the active buffer's in-memory contents; navigation then selects cached lines immediately, and movement during a cold load does not restart it. Editing, saving, reloading, changing source buffers, regaining terminal focus, or toggling blame invalidates the cached attribution. Missing, untracked, unborn, and non-repository files have no indicator; immutable historical buffers and comparison views do not receive live-file attribution. The indicator is hidden when the caret is offscreen or the full label cannot fit; wrapped lines use their final visible continuation without covering text, selections, or the caret. Popups retain their normal layering. Loading happens in the background without network access or changing repository refs/checkouts. Attribution is loaded once for the active buffer's in-memory contents; navigation then selects cached lines immediately, and movement during a cold load does not restart it. Editing, saving, reloading, changing source buffers, regaining terminal focus, or toggling blame invalidates the cached attribution. Historical sides use their captured commit and side-specific path, including renamed or deleted files no longer on disk; historical tabs retain that source and any privately fetched objects after the comparison closes. Frozen Working sides use their captured parent baseline rather than a later checkout. Missing, untracked, unborn, and non-repository live files have no indicator. Long attribution labels clip to the spare cells after the source text instead of disappearing entirely. The indicator is hidden when the caret is offscreen or no inline space remains; wrapped lines use their final visible continuation without covering text, selections, or the caret. Popups retain their normal layering.
Use `:git_blame.toggle`, the palette action, or remappable `config.core.git_blame.toggle`; there is no default keybinding. Set `config.git_blame = false` to disable it at startup. Disabling stops the worker and clears the label without changing text or undo history. Use `:git_blame.toggle`, the palette action, or remappable `config.core.git_blame.toggle`; there is no default keybinding. Set `config.git_blame = false` to disable it at startup. Disabling stops the worker and clears the label without changing text or undo history.
@@ -442,7 +442,9 @@ Comparisons start in **split, vertical (left/right)** view by default. In Normal
Diff metadata lives in the text-pane borders instead of a separate info row: split panes show their own Before/After revision labels at the bottom-left; a single visible pane shows the combined `before → after` label there. Each pane shows its read-only/editable state at the top-right, independently of keyboard focus. Before is always read-only; After is editable only for an eligible Working-checkout or checkout-matched pinned file. The changed-file explorer and panes start directly below configured chrome, and there is no native-buffer help footer. Loading/error/file notices remain visible when applicable. Narrow borders prioritize state and clip titles without painting over the gutter or source text. Diff metadata lives in the text-pane borders instead of a separate info row: split panes show their own Before/After revision labels at the bottom-left; a single visible pane shows the combined `before → after` label there. Each pane shows its read-only/editable state at the top-right, independently of keyboard focus. Before is always read-only; After is editable only for an eligible Working-checkout or checkout-matched pinned file. The changed-file explorer and panes start directly below configured chrome, and there is no native-buffer help footer. Loading/error/file notices remain visible when applicable. Narrow borders prioritize state and clip titles without painting over the gutter or source text.
Default diff tab titles show `Diff · +N -M · filename`, with added and removed line counts for the selected file, not the whole comparison. Replaced lines count on both sides; unchanged context does not count. Counts update with live edits and undo/redo, including when the diff tab is inactive. Revision selectors and files still loading omit counts until their text diff is ready. Custom chrome templates include this prefix through `{view}`. Default diff tab titles show `Diff · filename +N -M`, with added and removed line counts for the selected file. Replaced lines count on both sides; unchanged context does not count. Counts update with live edits and undo/redo, including when the diff tab is inactive. Revision selectors and files still loading omit per-file counts until their text diff is ready. Custom chrome templates use `{view}{name}{changes}` for this layout.
The default top-right `:diff` segment shows `Diff +N -M` across the entire active comparison, including unopened and filtered-out files. Unopened files use native Git text statistics from frozen snapshot blobs; loaded files use their displayed hunks, including live edits. Binary and mode-only changes contribute no text lines. Git's binary detection and diff algorithm can differ from the renderer for non-UTF-8 content, late NUL bytes, or difficult comparisons, so opening such a file can adjust totals. Oversized blobs, unmaterialized jj conflict projections, or failed statistics collection leave totals as `Diff …` until all counts are known; partial sums are never shown as complete totals.
For jj-managed repositories, revision entries and border labels display only the change ID; Git repositories keep commit-ID labels, and named bookmarks retain their names. The backing commit ID remains the exact comparison target and can still be used to filter revisions. For jj-managed repositories, revision entries and border labels display only the change ID; Git repositories keep commit-ID labels, and named bookmarks retain their names. The backing commit ID remains the exact comparison target and can still be used to filter revisions.
@@ -462,7 +464,7 @@ Mark or unmark a diff file with **`c` while browsing the changed-file tree**, **
Collapsed folders show a green **`✓`** when every changed file beneath them is reviewed, including nested files and descendants hidden by filtering. Expanded folders stay unmarked. Folder marks update automatically when a descendant's review mark or the changed-file catalog changes. Collapsed folders show a green **`✓`** when every changed file beneath them is reviewed, including nested files and descendants hidden by filtering. Expanded folders stay unmarked. Folder marks update automatically when a descendant's review mark or the changed-file catalog changes.
Review marks are saved with the selected local review and restored when the same immutable Before/After revision pair and file paths are reopened. They survive filtering, file navigation, lazy loading, tabs, split/unified/orientation changes, and pinned checkout-only transitions. Other comparisons do not inherit their marks; returning to a previously reviewed comparison restores them. Editing a writable After file clears only that file's mark and saves the removal; unchanged refreshes and saves preserve it. Marks on live text also retain its exact contents, including the final newline, so different unsaved text cannot inherit a checkmark. Marks do not change document text, undo history, or repository state. Without configured SQLite storage, use `:diff.review.save path` to save them as JSON. Review marks are saved with the selected local review and restored by immutable per-file Before/After identities and file paths. Changes to unrelated files or commit metadata do not clear them; changes to either side's bytes, final newline, file mode, or rename paths require re-review. They survive filtering, file navigation, lazy loading, tabs, split/unified/orientation changes, and pinned checkout-only transitions. Editing a writable After file clears only that file's mark and saves the removal; unchanged refreshes and saves preserve it. Marks on live text also retain its exact contents, including the final newline, so different unsaved text cannot inherit a checkmark. Marks do not change document text, undo history, or repository state. Without configured SQLite storage, use `:diff.review.save path` to save them as JSON. Older revision-pair-scoped marks remain stored but must be marked again to acquire per-file identities; comments, summaries, and verdicts are unaffected.
**Local review comments** share local storage with the file-reviewed checkmarks above; neither feature publishes to GitHub. 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. **Local review comments** share local storage with the file-reviewed checkmarks above; neither feature publishes to GitHub. 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.
@@ -478,6 +480,7 @@ Local review buffers (comments, summaries, overview, and stored-review selector)
| `:diff.review.resolve [id]` / `:diff.review.unresolve [id]` | Mark a comment resolved/unresolved; uses the same default selection as edit/delete | | `:diff.review.resolve [id]` / `:diff.review.unresolve [id]` | Mark a comment resolved/unresolved; uses the same default selection as edit/delete |
| `: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 |
| `:diff.review.overview` | Refresh/open a read-only summary and comment list; Enter edits the entry under the caret | | `:diff.review.overview` | Refresh/open a read-only summary and comment list; Enter edits the entry under the caret |
| `:diff.review.copy` | Copy all saved comments in the selected review to the configured clipboard, using the CLI text format |
| `:diff.review.summary` | Compose the review summary | | `:diff.review.summary` | Compose the review summary |
| `:diff.review.verdict looks_good` | Record **Looks good** | | `:diff.review.verdict looks_good` | Record **Looks good** |
| `:diff.review.verdict changes_requested` | Record **Changes requested**; `none` clears the verdict | | `:diff.review.verdict changes_requested` | Record **Changes requested**; `none` clears the verdict |
@@ -487,6 +490,8 @@ Local review buffers (comments, summaries, overview, and stored-review selector)
Comment and summary text is committed only by **`:w`** or **`:diff.review.save`**, which saves the entire review, including hidden drafts. Dismissing an editor discards its unsaved text; switching between review editors can retain hidden drafts but does not save them. File checkmarks, verdicts, deletions, and manual resolution changes commit immediately to local SQLite without committing draft text. `:diff.review.save path` or `:w path` additionally exports JSON; later metadata autosaves still go to SQLite and leave that export unchanged. Imports create a new stored review identity rather than overwriting another review. Comment and summary text is committed only by **`:w`** or **`:diff.review.save`**, which saves the entire review, including hidden drafts. Dismissing an editor discards its unsaved text; switching between review editors can retain hidden drafts but does not save them. File checkmarks, verdicts, deletions, and manual resolution changes commit immediately to local SQLite without committing draft text. `:diff.review.save path` or `:w path` additionally exports JSON; later metadata autosaves still go to SQLite and leave that export unchanged. Imports create a new stored review identity rather than overwriting another review.
`:diff.review.copy` copies the same text as `vamp --review show ID --all`: repository, summary, verdict, counts, and every saved comment with its anchor and exact excerpt. Resolved and stale comments are included, across all files in the selected review. `clipboard_mode` chooses the system clipboard or Separate-mode internal register. Copying does not save or include unsaved comment/summary drafts. No comments or a clipboard write failure reports an error without replacing the register.
From the shell, use `vamp --review delete REVIEW_ID COMMENT_ID [--repo PATH] [--json]` to delete a stored comment immediately without opening the editor. Find IDs with `vamp --review list` and `vamp --review show REVIEW_ID --all`. The repository defaults to the current checkout. Both resolved and unresolved comments can be deleted; the review and any JSON exports remain intact. An editor holding the old review revision must reload before saving. From the shell, use `vamp --review delete REVIEW_ID COMMENT_ID [--repo PATH] [--json]` to delete a stored comment immediately without opening the editor. Find IDs with `vamp --review list` and `vamp --review show REVIEW_ID --all`. The repository defaults to the current checkout. Both resolved and unresolved comments can be deleted; the review and any JSON exports remain intact. An editor holding the old review revision must reload before saving.
Storage defaults to `$XDG_DATA_HOME/vamp/reviews/reviews.sqlite3`, or `~/.local/share/vamp/reviews/reviews.sqlite3` when XDG_DATA_HOME is unset or relative. Configure `review_directory` to override its directory; relative configuration paths resolve against the config file. Repository identity is the canonical checkout root. Each review has a persistent database ID independent of comparison revisions, so reopening changed revisions retains stale comments. Opening a comparison automatically restores the repository's sole review. Multiple reviews open a floating selector: move to an entry and press Enter, or use `:diff.review.open id`. Use `:diff.review.new` for another independent review. Storage defaults to `$XDG_DATA_HOME/vamp/reviews/reviews.sqlite3`, or `~/.local/share/vamp/reviews/reviews.sqlite3` when XDG_DATA_HOME is unset or relative. Configure `review_directory` to override its directory; relative configuration paths resolve against the config file. Repository identity is the canonical checkout root. Each review has a persistent database ID independent of comparison revisions, so reopening changed revisions retains stale comments. Opening a comparison automatically restores the repository's sole review. Multiple reviews open a floating selector: move to an entry and press Enter, or use `:diff.review.open id`. Use `:diff.review.new` for another independent review.
@@ -495,9 +500,9 @@ Write errors, locked databases, incompatible schemas, and conflicting edits from
The current SQLite format is schema 3: typed `reviews`, `comments`, and `reviewed_files` tables, not serialized JSON documents. Older databases are incompatible and are left untouched; there is no migration or automatic reset. JSON version 4 is the sharing format, independent of the database schema. The current SQLite format is schema 3: typed `reviews`, `comments`, and `reviewed_files` tables, not serialized JSON documents. Older databases are incompatible and are left untouched; there is no migration or automatic reset. JSON version 4 is the sharing format, independent of the database schema.
Version 4 JSON is the agent-readable sharing format: repository root, summary, optional verdict, comments, and a required `reviewed_files` array. Each reviewed file stores its immutable `revisions` pair, relative `path`, optional rename `old_path`, and optional exact live `after` text. Comments have stable IDs, bodies, required boolean `resolved` states, and anchors. New comments are unresolved. Database review IDs and optimistic storage revisions stay internal. 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. Older JSON formats are rejected and preserved without migration. Working comparisons use content-addressed snapshot IDs; captured text additionally freezes live unsaved edits. Stored reviews have a 64 MiB aggregate text budget including 64 bytes of overhead per comment and reviewed file; imported/exported JSON files have a separate 64 MiB serialized-size limit. Version 4 JSON is the agent-readable sharing format: repository root, summary, optional verdict, comments, and a required `reviewed_files` array. Each reviewed file stores its immutable per-file `revisions` pair (`file:` identities encoding mode, Git object ID or exact data, and notices), relative `path`, optional rename `old_path`, and optional exact live `after` text. Comments have stable IDs, bodies, required boolean `resolved` states, and anchors. New comments are unresolved. Database review IDs and optimistic storage revisions stay internal. 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. Older JSON formats are rejected and preserved without migration. Working comparisons use content-addressed snapshot IDs; captured text additionally freezes live unsaved edits. Stored reviews have a 64 MiB aggregate text budget including 64 bytes of overhead per comment and reviewed file; imported/exported JSON files have a separate 64 MiB serialized-size limit.
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. Unchanged comment ranges follow edits elsewhere in the file, including inserted/deleted lines above and column shifts before a partial-line selection. Working-snapshot refreshes can retain these attachments; unrelated immutable revisions and paths do not match. Mapping requires an unchanged, unambiguous text region containing the selection. Modified/deleted selections or ambiguous repeated regions are **stale**: comments remain available through the overview and next/previous navigation, with original snapshot coordinates and no current-source marker or jump. Saved anchors and exported excerpts remain unchanged. Unloaded files are shown as **not checked** until visited; refresh the overview to update its statuses. There is no server, authentication, or review-publishing operation.
Resolution is a manual human decision, independent of anchor freshness: a comment can be both resolved and stale. Overview entries and comment titles label resolved/unresolved status. Resolved comments remain in the overview and next/previous navigation, and can still be edited or reopened. Resolving never changes an anchor or deletes feedback. Resolution is a manual human decision, independent of anchor freshness: a comment can be both resolved and stale. Overview entries and comment titles label resolved/unresolved status. Resolved comments remain in the overview and next/previous navigation, and can still be edited or reopened. Resolving never changes an anchor or deletes feedback.
+2 -2
View File
@@ -29,9 +29,9 @@ Vamp.configure do |config|
position: { padding: 1, leading_edge: "░▓", trailing_edge: "" } position: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
} }
config.bar.bottom = { config.bar.bottom = {
left: [:tabs], center: [], right: [:shell, :battery], left: [:tabs], center: [], right: [:diff, :shell, :battery],
tabs: { tabs: {
title: "{index} {view}{name}{modified}", padding: 1, title: "{index} {view}{name}{changes}{modified}", padding: 1,
leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: "" leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: ""
}, },
battery: { padding: 1, leading_edge: "░▓", trailing_edge: "" } battery: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
+118 -53
View File
@@ -17,7 +17,7 @@ pub(crate) const LIMIT: usize = 16 * 1024 * 1024;
const TIMEOUT: Duration = Duration::from_secs(5); const TIMEOUT: Duration = Duration::from_secs(5);
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
type ResultFile = Result<Option<Vec<Option<Arc<LineBlame>>>>, String>; type ResultFile = Result<Option<Vec<Option<Arc<LineBlame>>>>, String>;
type Request = (u64, u64, PathBuf, String); type Request = (u64, u64, PathBuf, String, Option<RevisionSource>);
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub(crate) struct LineBlame { pub(crate) struct LineBlame {
@@ -27,6 +27,14 @@ pub(crate) struct LineBlame {
pub(crate) uncommitted: bool, pub(crate) uncommitted: bool,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct RevisionSource {
pub(crate) store: PathBuf,
pub(crate) commit: String,
pub(crate) path: PathBuf,
pub(crate) keepalive: Option<Arc<crate::repository::Temporary>>,
}
#[derive(Default)] #[derive(Default)]
struct State { struct State {
request: Option<Request>, request: Option<Request>,
@@ -63,13 +71,16 @@ impl BlameWorker {
if worker.stopped.load(Ordering::Acquire) { if worker.stopped.load(Ordering::Acquire) {
break; break;
} }
let (generation, id, path, contents) = state.request.take().unwrap(); let (generation, id, path, contents, source) = state.request.take().unwrap();
drop(state); drop(state);
let cancelled = || { let cancelled = || {
worker.stopped.load(Ordering::Acquire) worker.stopped.load(Ordering::Acquire)
|| worker.generation.load(Ordering::Acquire) != generation || worker.generation.load(Ordering::Acquire) != generation
}; };
let result = read_file(&path, &contents, &cancelled); let result = match source {
Some(source) => read_revision(&source, &contents, &cancelled),
None => read_file(&path, &contents, &cancelled),
};
let Ok(mut state) = worker.state.lock() else { let Ok(mut state) = worker.state.lock() else {
return; return;
}; };
@@ -84,7 +95,13 @@ impl BlameWorker {
}) })
} }
pub(crate) fn request(&mut self, id: u64, path: PathBuf, contents: String) -> io::Result<()> { pub(crate) fn request(
&mut self,
id: u64,
path: PathBuf,
contents: String,
source: Option<RevisionSource>,
) -> io::Result<()> {
if contents.len() > LIMIT { if contents.len() > LIMIT {
return Err(io::Error::new( return Err(io::Error::new(
io::ErrorKind::InvalidInput, io::ErrorKind::InvalidInput,
@@ -101,7 +118,7 @@ impl BlameWorker {
.generation .generation
.fetch_add(1, Ordering::AcqRel) .fetch_add(1, Ordering::AcqRel)
.wrapping_add(1); .wrapping_add(1);
state.request = Some((generation, id, path, contents)); state.request = Some((generation, id, path, contents, source));
state.result = None; state.result = None;
self.shared.wake.notify_one(); self.shared.wake.notify_one();
Ok(()) Ok(())
@@ -279,19 +296,93 @@ pub(crate) fn git_without_literal_pathspecs(root: &Path) -> Command {
fn oid(value: &str) -> bool { fn oid(value: &str) -> bool {
matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit()) matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
} }
fn path_bytes(bytes: &[u8]) -> Result<PathBuf, String> { fn read_revision(
#[cfg(unix)] source: &RevisionSource,
contents: &str,
cancelled: &dyn Fn() -> bool,
) -> ResultFile {
if contents.len() > LIMIT {
return Err("Blame input exceeds 16 MiB".into());
}
if !oid(&source.commit) {
return Err("Invalid blame commit".into());
}
crate::repository::safe_path(&source.path)?;
let rows = contents.split_inclusive('\n').count();
if rows == 0 || source.commit.bytes().all(|byte| byte == b'0') {
return Ok(None);
}
let mut objects = git(&source.store);
objects.arg("--git-dir").arg(&source.store).args([
"rev-parse",
"--path-format=absolute",
"--git-path",
"objects",
]);
let objects = run(objects, &[], cancelled)?.ok_or("Cannot resolve blame object store")?;
let private = Temporary::new()?;
fs::create_dir(private.0.join("refs"))
.and_then(|()| fs::create_dir_all(private.0.join("objects/info")))
.map_err(|_| "Cannot prepare private blame repository")?;
// Keep relative alternates anchored in their original object directory.
let mut alternate = vec![b'"'];
for &byte in objects.strip_suffix(b"\n").unwrap_or(&objects) {
if (b' '..=b'~').contains(&byte) && !matches!(byte, b'"' | b'\\') {
alternate.push(byte);
} else {
write!(&mut alternate, "\\{byte:03o}").unwrap();
}
}
alternate.extend_from_slice(b"\"\n");
fs::write(private.0.join("objects/info/alternates"), alternate)
.and_then(|()| fs::write(private.0.join("HEAD"), format!("{}\n", source.commit)))
.map_err(|_| "Cannot prepare blame parent")?;
if source.commit.len() == 64 {
fs::write(
private.0.join("config"),
"[core]\nrepositoryformatversion = 1\n[extensions]\nobjectFormat = sha256\n",
)
.map_err(|_| "Cannot prepare blame object format")?;
}
let command = || {
let mut command = git(&private.0);
command
.arg("--git-dir")
.arg(&private.0)
.arg("--work-tree")
.arg(&private.0);
command
};
let mut present = command();
present
.args(["ls-tree", "-r", "-z", "--full-tree", "HEAD", "--"])
.arg(&source.path);
let Some(entry) = run(present, &[], cancelled)? else {
return Ok(None);
};
let Some(tab) = entry.iter().position(|&byte| byte == b'\t') else {
return Ok(None);
};
if (!entry.starts_with(b"100644 blob ") && !entry.starts_with(b"100755 blob "))
|| entry[tab + 1..].strip_suffix(b"\0") != Some(source.path.as_os_str().as_encoded_bytes())
{ {
use std::{ffi::OsString, os::unix::ffi::OsStringExt}; return Ok(None);
Ok(PathBuf::from(OsString::from_vec(bytes.to_vec())))
}
#[cfg(not(unix))]
{
Ok(PathBuf::from(
std::str::from_utf8(bytes).map_err(|_| "Invalid repository path")?,
))
} }
let mut blame = command();
blame
.args([
"blame",
"--incremental",
"--no-textconv",
"--contents",
"-",
"--",
])
.arg(&source.path);
let output = run(blame, contents.as_bytes(), cancelled)?.ok_or("Cannot read Git blame")?;
parse(&output, rows).map(Some)
} }
fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> ResultFile { fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> ResultFile {
if contents.len() > LIMIT { if contents.len() > LIMIT {
return Err("Blame input exceeds 16 MiB".into()); return Err("Blame input exceeds 16 MiB".into());
@@ -320,7 +411,6 @@ fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> Resul
}) { }) {
return Ok(None); return Ok(None);
} }
let mut temporary = None;
let mut command = git(root); let mut command = git(root);
if root.join(".jj").exists() { if root.join(".jj").exists() {
let mut jj = Command::new("jj"); let mut jj = Command::new("jj");
@@ -352,44 +442,19 @@ fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> Resul
if !oid(parent) { if !oid(parent) {
return Err("Invalid Jujutsu blame metadata".into()); return Err("Invalid Jujutsu blame metadata".into());
} }
let mut repo = root.join(".jj/repo"); let store = crate::repository::store_for_jj(root)?;
if repo.is_file() { return read_revision(
let pointer = fs::read(&repo).map_err(|_| "Cannot read Jujutsu repository pointer")?; &RevisionSource {
repo = root store,
.join(".jj") commit: parent.into(),
.join(path_bytes(pointer.strip_suffix(b"\n").unwrap_or(&pointer))?); path: relative.to_path_buf(),
keepalive: None,
},
contents,
cancelled,
);
} }
let store = repo.join("store"); let mut present = git(root);
let target = fs::read(store.join("git_target"))
.map_err(|_| "Only Git-backed Jujutsu blame is supported")?;
let store = fs::canonicalize(
store.join(path_bytes(target.strip_suffix(b"\n").unwrap_or(&target))?),
)
.map_err(|_| "Cannot resolve Jujutsu Git store")?;
let private = Temporary::new()?;
fs::create_dir(private.0.join("refs"))
.map_err(|_| "Cannot prepare private blame repository")?;
#[cfg(unix)]
std::os::unix::fs::symlink(store.join("objects"), private.0.join("objects"))
.map_err(|_| "Cannot link blame objects")?;
#[cfg(not(unix))]
return Err("Jujutsu blame requires Unix object links".into());
fs::write(private.0.join("HEAD"), format!("{parent}\n"))
.map_err(|_| "Cannot prepare blame parent")?;
command
.arg("--git-dir")
.arg(&private.0)
.arg("--work-tree")
.arg(root);
temporary = Some(private);
}
let mut present = if let Some(private) = &temporary {
let mut command = git(root);
command.arg("--git-dir").arg(&private.0);
command
} else {
git(root)
};
let mut object = std::ffi::OsString::from("HEAD:"); let mut object = std::ffi::OsString::from("HEAD:");
object.push(relative); object.push(relative);
present.args(["cat-file", "-e"]).arg(object); present.args(["cat-file", "-e"]).arg(object);
+14 -7
View File
@@ -117,9 +117,10 @@ impl ChromeLine {
Self { Self {
left: vec!["tabs".into()], left: vec!["tabs".into()],
center: Vec::new(), center: Vec::new(),
right: vec!["shell".into(), "battery".into()], right: vec!["diff".into(), "shell".into(), "battery".into()],
segments: BTreeMap::from([ segments: BTreeMap::from([
("tabs".into(), default_segment("tabs")), ("tabs".into(), default_segment("tabs")),
("diff".into(), default_segment("diff")),
("battery".into(), default_segment("battery")), ("battery".into(), default_segment("battery")),
("shell".into(), default_segment("shell")), ("shell".into(), default_segment("shell")),
]), ]),
@@ -194,17 +195,20 @@ impl ChromeLine {
fn default_segment(name: &str) -> SegmentConfig { fn default_segment(name: &str) -> SegmentConfig {
let mut segment = SegmentConfig::default(); let mut segment = SegmentConfig::default();
if matches!(name, "tabs" | "position" | "battery" | "shell") { if matches!(name, "tabs" | "position" | "battery" | "shell" | "diff") {
segment.leading_edge = "░▓".into(); segment.leading_edge = "░▓".into();
} }
if matches!(name, "tabs" | "mode" | "shell") { if matches!(name, "tabs" | "mode" | "shell" | "diff") {
segment.trailing_edge = "▓░".into(); segment.trailing_edge = "▓░".into();
} }
if matches!(name, "tabs" | "mode" | "position" | "battery" | "shell") { if matches!(
name,
"tabs" | "mode" | "position" | "battery" | "shell" | "diff" | "lsp"
) {
segment.padding = 1; segment.padding = 1;
} }
if name == "tabs" { if name == "tabs" {
segment.title = "{index} {view}{name}{modified}".into(); segment.title = "{index} {view}{name}{changes}{modified}".into();
segment.first_leading_edge = Some(String::new()); segment.first_leading_edge = Some(String::new());
} }
segment segment
@@ -233,9 +237,12 @@ fn validate_title(title: &str) -> Result<(), String> {
} }
let end = rest.find('}').ok_or("title contains an unmatched '{'")?; let end = rest.find('}').ok_or("title contains an unmatched '{'")?;
let placeholder = &rest[1..end]; let placeholder = &rest[1..end];
if !matches!(placeholder, "index" | "view" | "name" | "path" | "modified") { if !matches!(
placeholder,
"index" | "view" | "name" | "path" | "changes" | "modified"
) {
return Err(format!( return Err(format!(
"unknown title placeholder {{{placeholder}}}; expected index, view, name, path, or modified" "unknown title placeholder {{{placeholder}}}; expected index, view, name, path, changes, or modified"
)); ));
} }
rest = &rest[end + 1..]; rest = &rest[end + 1..];
+64 -12
View File
@@ -4,6 +4,7 @@ use std::{
borrow::Cow, borrow::Cow,
collections::{BTreeMap, BTreeSet}, collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc,
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
@@ -127,11 +128,15 @@ pub struct Hunk {
} }
#[derive(Debug)] #[derive(Debug)]
pub struct ChangedFile { pub struct ChangedFile {
/// Immutable file-side identities, independent of unrelated revision changes.
pub review_revisions: Option<[String; 2]>,
pub path: PathBuf, pub path: PathBuf,
pub old_path: Option<PathBuf>, pub old_path: Option<PathBuf>,
pub status: FileStatus, pub status: FileStatus,
/// False means text, EOF flags, hunks and notices have not been prepared. /// False means text, EOF flags, hunks and notices have not been prepared.
pub loaded: bool, pub loaded: bool,
/// Frozen (added, removed) counts for catalog entries whose text is unloaded.
pub stats: Option<(usize, usize)>,
pub before: Vec<String>, pub before: Vec<String>,
pub after: Vec<String>, pub after: Vec<String>,
pub before_eof: bool, pub before_eof: bool,
@@ -139,10 +144,30 @@ pub struct ChangedFile {
pub hunks: Vec<Hunk>, pub hunks: Vec<Hunk>,
pub notice: Option<String>, pub notice: Option<String>,
} }
impl ChangedFile {
pub(crate) fn line_counts(&self) -> Option<(usize, usize)> {
if self.loaded {
Some(self.hunks.iter().fold((0, 0), |(added, removed), hunk| {
(added + hunk.after_len, removed + hunk.before_len)
}))
} else {
self.stats
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BlameRevision {
pub store: PathBuf,
pub commit: String,
pub(crate) keepalive: Option<Arc<crate::repository::Temporary>>,
}
#[derive(Debug)] #[derive(Debug)]
pub struct Comparison { pub struct Comparison {
/// Immutable comparison-context IDs; captured review text also freezes live edits. /// Immutable comparison-context IDs; captured review text also freezes live edits.
pub revisions: [String; 2], pub revisions: [String; 2],
/// Git object stores and immutable attribution baselines for Before/After.
pub blame: [Option<BlameRevision>; 2],
pub before: String, pub before: String,
pub after: String, pub after: String,
pub files: Vec<ChangedFile>, pub files: Vec<ChangedFile>,
@@ -668,6 +693,7 @@ impl DiffState {
pub(crate) fn set_resolver_paths(&mut self, root: PathBuf, paths: Vec<PathBuf>, file: usize) { pub(crate) fn set_resolver_paths(&mut self, root: PathBuf, paths: Vec<PathBuf>, file: usize) {
let comparison = self.comparison.get_or_insert_with(|| Comparison { let comparison = self.comparison.get_or_insert_with(|| Comparison {
revisions: Default::default(), revisions: Default::default(),
blame: Default::default(),
before: "Resolution".into(), before: "Resolution".into(),
after: "Other version".into(), after: "Other version".into(),
files: Vec::new(), files: Vec::new(),
@@ -676,6 +702,8 @@ impl DiffState {
}); });
comparison.root = root.clone(); comparison.root = root.clone();
comparison.files.resize_with(paths.len(), || ChangedFile { comparison.files.resize_with(paths.len(), || ChangedFile {
review_revisions: None,
stats: None,
path: PathBuf::new(), path: PathBuf::new(),
old_path: None, old_path: None,
status: FileStatus::Modified, status: FileStatus::Modified,
@@ -1801,11 +1829,20 @@ impl DiffState {
} }
let records = &mut self.local_review.document.reviewed_files; let records = &mut self.local_review.document.reviewed_files;
records.retain(|record| { records.retain(|record| {
record.revisions != comparison.revisions || record.path != file.path record.revisions
!= *file
.review_revisions
.as_ref()
.unwrap_or(&comparison.revisions)
|| record.path != file.path
}); });
if reviewed { if reviewed {
records.push(crate::review::ReviewedFile { records.push(crate::review::ReviewedFile {
revisions: comparison.revisions.clone(), revisions: file
.review_revisions
.as_ref()
.unwrap_or(&comparison.revisions)
.clone(),
path: file.path.clone(), path: file.path.clone(),
old_path: file.old_path.clone(), old_path: file.old_path.clone(),
after: self after: self
@@ -1832,12 +1869,16 @@ impl DiffState {
.document .document
.reviewed_files .reviewed_files
.iter() .iter()
.filter(|record| record.revisions == comparison.revisions) .map(|record| ((&record.revisions, &record.path), record))
.map(|record| (&record.path, record))
.collect(); .collect();
for (index, file) in comparison.files.iter().enumerate() { for (index, file) in comparison.files.iter().enumerate() {
let Some(record) = records let Some(record) = records
.get(&file.path) .get(&(
file.review_revisions
.as_ref()
.unwrap_or(&comparison.revisions),
&file.path,
))
.filter(|record| record.old_path == file.old_path) .filter(|record| record.old_path == file.old_path)
else { else {
continue; continue;
@@ -2119,17 +2160,24 @@ impl DiffState {
lines lines
} }
pub fn review_anchor_current(&self, anchor: &crate::review::Anchor) -> bool { pub fn review_anchor_range(
&self,
anchor: &crate::review::Anchor,
) -> Option<std::ops::Range<crate::plugin::TextPosition>> {
use crate::review::Side; use crate::review::Side;
let Some(comparison) = &self.comparison else { let Some(comparison) = &self.comparison else {
return false; return None;
}; };
if self.local_review.document.root != comparison.root { if self.local_review.document.root != comparison.root {
return false; return None;
} }
comparison.files.iter().enumerate().any(|(index, file)| { comparison
.files
.iter()
.enumerate()
.find_map(|(index, file)| {
if !file.loaded || file.notice.is_some() { if !file.loaded || file.notice.is_some() {
return false; return None;
} }
let (path, lines, eof) = match anchor.side { let (path, lines, eof) = match anchor.side {
Side::Before => ( Side::Before => (
@@ -2143,7 +2191,7 @@ impl DiffState {
file.after_eof && !self.live_files.contains(&index), file.after_eof && !self.live_files.contains(&index),
), ),
}; };
anchor.matches_lines( anchor.mapped_range(
&comparison.revisions[anchor.side.index()], &comparison.revisions[anchor.side.index()],
path, path,
anchor.side, anchor.side,
@@ -2263,7 +2311,11 @@ impl DiffState {
.reviewed_files .reviewed_files
.iter() .iter()
.position(|record| { .position(|record| {
record.revisions == comparison.revisions record.revisions
== *file
.review_revisions
.as_ref()
.unwrap_or(&comparison.revisions)
&& record.path == file.path && record.path == file.path
&& record.old_path == file.old_path && record.old_path == file.old_path
}) })
+24 -4
View File
@@ -1041,6 +1041,12 @@ core_actions![
&[Mode::Normal], &[Mode::Normal],
true true
), ),
(
"core.diff.review.copy",
Action::ReviewCopy,
&[Mode::Normal],
true
),
( (
"core.diff.review.summary", "core.diff.review.summary",
Action::ReviewSummary, Action::ReviewSummary,
@@ -1952,6 +1958,7 @@ struct Buffer {
resolver_readonly: bool, resolver_readonly: bool,
snapshot_document_path: Option<PathBuf>, snapshot_document_path: Option<PathBuf>,
snapshot_root: Option<PathBuf>, snapshot_root: Option<PathBuf>,
snapshot_blame: Option<Arc<crate::blame::RevisionSource>>,
lines: Vec<String>, lines: Vec<String>,
saved_lines: Vec<String>, saved_lines: Vec<String>,
// Invalidate whenever text or the saved baseline changes, including temporary swaps. // Invalidate whenever text or the saved baseline changes, including temporary swaps.
@@ -2012,6 +2019,7 @@ impl Buffer {
resolver_readonly: false, resolver_readonly: false,
snapshot_document_path: None, snapshot_document_path: None,
snapshot_root: None, snapshot_root: None,
snapshot_blame: None,
saved_lines: lines.clone(), saved_lines: lines.clone(),
dirty_cache: Cell::new(Some(false)), dirty_cache: Cell::new(Some(false)),
disk_stamp, disk_stamp,
@@ -2175,6 +2183,7 @@ pub(crate) struct BlameSource {
buffer: usize, buffer: usize,
revision: u64, revision: u64,
pub(crate) path: Arc<Path>, pub(crate) path: Arc<Path>,
pub(crate) snapshot: Option<Arc<crate::blame::RevisionSource>>,
pub(crate) row: usize, pub(crate) row: usize,
save_revision: u64, save_revision: u64,
disk_stamp: Option<FileStamp>, disk_stamp: Option<FileStamp>,
@@ -5116,6 +5125,14 @@ impl Editor {
}; };
let source = comparison.root.join(relative); let source = comparison.root.join(relative);
let root = comparison.root.clone(); let root = comparison.root.clone();
let blame = comparison.blame[side].as_ref().map(|baseline| {
Arc::new(crate::blame::RevisionSource {
store: baseline.store.clone(),
commit: baseline.commit.clone(),
keepalive: baseline.keepalive.clone(),
path: relative.clone(),
})
});
let lines = diff.snapshot_lines_at(file_index, focus); let lines = diff.snapshot_lines_at(file_index, focus);
let key = ( let key = (
diff.comparison_id.expect("snapshot comparison"), diff.comparison_id.expect("snapshot comparison"),
@@ -5155,6 +5172,7 @@ impl Editor {
buffer.path = Some(source); buffer.path = Some(source);
buffer.snapshot_document_path = Some(document_path); buffer.snapshot_document_path = Some(document_path);
buffer.snapshot_root = Some(root); buffer.snapshot_root = Some(root);
buffer.snapshot_blame = blame;
buffer.lines = lines; buffer.lines = lines;
buffer.dirty_cache.set(None); buffer.dirty_cache.set(None);
buffer buffer
@@ -5424,6 +5442,7 @@ impl Editor {
| Action::ReviewNext | Action::ReviewNext
| Action::ReviewPrevious | Action::ReviewPrevious
| Action::ReviewOverview | Action::ReviewOverview
| Action::ReviewCopy
| Action::ReviewSummary | Action::ReviewSummary
| Action::ReviewVerdict | Action::ReviewVerdict
| Action::ReviewSave | Action::ReviewSave
@@ -7156,8 +7175,7 @@ impl Editor {
pub(crate) fn blame_source(&self) -> Option<BlameSource> { pub(crate) fn blame_source(&self) -> Option<BlameSource> {
if !self.git_blame if !self.git_blame
|| self.should_quit() || self.should_quit()
|| self.diff.is_some() || (self.active.diff_snapshot.is_some() && self.active.snapshot_blame.is_none())
|| self.active.diff_snapshot.is_some()
|| self.active.reload_error.is_some() || self.active.reload_error.is_some()
{ {
return None; return None;
@@ -7166,6 +7184,7 @@ impl Editor {
buffer: self.buffer_id(), buffer: self.buffer_id(),
revision: self.text_revision(), revision: self.text_revision(),
path: Arc::from(self.path()?), path: Arc::from(self.path()?),
snapshot: self.active.snapshot_blame.clone(),
row: self.cursor().row, row: self.cursor().row,
save_revision: self.active.save_revision, save_revision: self.active.save_revision,
disk_stamp: self.active.disk_stamp, disk_stamp: self.active.disk_stamp,
@@ -7177,13 +7196,12 @@ impl Editor {
pub(crate) fn blame_file_source_is_current(&self, source: &BlameSource) -> bool { pub(crate) fn blame_file_source_is_current(&self, source: &BlameSource) -> bool {
self.git_blame self.git_blame
&& !self.should_quit() && !self.should_quit()
&& self.diff.is_none()
&& self.active.diff_snapshot.is_none()
&& self.active.reload_error.is_none() && self.active.reload_error.is_none()
&& source.buffer == self.buffer_id() && source.buffer == self.buffer_id()
&& source.revision == self.text_revision() && source.revision == self.text_revision()
&& source.save_revision == self.active.save_revision && source.save_revision == self.active.save_revision
&& source.disk_stamp == self.active.disk_stamp && source.disk_stamp == self.active.disk_stamp
&& source.snapshot == self.active.snapshot_blame
&& Some(source.path.as_ref()) == self.path() && Some(source.path.as_ref()) == self.path()
} }
@@ -8856,6 +8874,7 @@ impl Editor {
| Action::ReviewNext | Action::ReviewNext
| Action::ReviewPrevious | Action::ReviewPrevious
| Action::ReviewOverview | Action::ReviewOverview
| Action::ReviewCopy
| Action::ReviewSummary | Action::ReviewSummary
| Action::ReviewVerdict | Action::ReviewVerdict
| Action::ReviewSave | Action::ReviewSave
@@ -9814,6 +9833,7 @@ impl Editor {
| Action::ReviewNext | Action::ReviewNext
| Action::ReviewPrevious | Action::ReviewPrevious
| Action::ReviewOverview | Action::ReviewOverview
| Action::ReviewCopy
| Action::ReviewSummary | Action::ReviewSummary
| Action::ReviewVerdict | Action::ReviewVerdict
| Action::ReviewSave | Action::ReviewSave
+42 -21
View File
@@ -460,7 +460,7 @@ impl Editor {
{ {
self.review_diff_mut().unwrap().local_review.document = Review::new(root.clone()); self.review_diff_mut().unwrap().local_review.document = Review::new(root.clone());
} }
if !matches!(action, Action::ReviewSave) { if !matches!(action, Action::ReviewSave | Action::ReviewCopy) {
self.autosave_local_review()?; self.autosave_local_review()?;
} }
if action == Action::ReviewOpen { if action == Action::ReviewOpen {
@@ -539,6 +539,20 @@ impl Editor {
self.show_review_comment(comments[index].id)?; self.show_review_comment(comments[index].id)?;
} }
Action::ReviewOverview => self.open_review_overview()?, Action::ReviewOverview => self.open_review_overview()?,
Action::ReviewCopy => {
let review = &self.workspace_diff().unwrap().local_review.document;
let count = review.comments.len();
if count == 0 {
return Err("No local review comments".into());
}
let text = review.format_text(true);
self.store_clipboard(
vec![text],
self.clipboard_mode == ClipboardMode::System,
false,
)?;
self.set_message(format!("Copied {count} review comments"));
}
Action::ReviewSummary => { Action::ReviewSummary => {
let text = self let text = self
.workspace_diff() .workspace_diff()
@@ -730,18 +744,24 @@ impl Editor {
} else { } else {
"unresolved" "unresolved"
}; };
let mapped = diff.review_anchor_range(anchor);
let current = mapped.is_some();
let location = if current {
"current"
} else {
"original snapshot"
};
let range = mapped.unwrap_or(anchor.start..anchor.end);
let title = format!( let title = format!(
"Comment #{id} · {status} · {}:{}:{}–{}:{} · {:?} · :w saves review", "Comment #{id} · {status} · {location} · {}:{}:{}–{}:{} · {:?} · :w saves review",
anchor.path.display(), anchor.path.display(),
anchor.start.row + 1, range.start.row + 1,
anchor.start.column + 1, range.start.column + 1,
anchor.end.row + 1, range.end.row + 1,
anchor.end.column + 1, range.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| {
(comparison.revisions[anchor.side.index()] == anchor.revision)
.then(|| {
comparison.files.iter().position(|file| { comparison.files.iter().position(|file| {
let path = if anchor.side == Side::Before { let path = if anchor.side == Side::Before {
file.old_path.as_ref().unwrap_or(&file.path) file.old_path.as_ref().unwrap_or(&file.path)
@@ -750,9 +770,9 @@ impl Editor {
}; };
path == &anchor.path path == &anchor.path
}) })
})
.flatten()
}); });
let stale = !current
&& target.is_none_or(|index| diff.comparison.as_ref().unwrap().files[index].loaded);
let start = anchor.start; let start = anchor.start;
let owner = self.diff_owner.unwrap(); let owner = self.diff_owner.unwrap();
self.select_pane(owner); self.select_pane(owner);
@@ -767,7 +787,7 @@ impl Editor {
self.place_navigation(start); self.place_navigation(start);
} }
self.open_review_buffer(ReviewText::Comment(id), &text, title)?; self.open_review_buffer(ReviewText::Comment(id), &text, title)?;
if target.is_none() { if stale {
self.set_message("Comment is stale; original snapshot anchor preserved"); self.set_message("Comment is stale; original snapshot anchor preserved");
} }
Ok(()) Ok(())
@@ -807,11 +827,11 @@ impl Editor {
if selected_path != Some(&comment.anchor.path) { if selected_path != Some(&comment.anchor.path) {
return; return;
} }
if !diff.review_anchor_current(&comment.anchor) { let Some(range) = diff.review_anchor_range(&comment.anchor) else {
self.set_message("Comment is stale; original snapshot anchor preserved"); self.set_message("Comment is stale; original snapshot anchor preserved");
return; return;
} };
let position = comment.anchor.start; let position = range.start;
let focus = if comment.anchor.side == Side::Before { let focus = if comment.anchor.side == Side::Before {
Focus::Before Focus::Before
} else { } else {
@@ -853,11 +873,12 @@ impl Editor {
); );
for comment in &state.document.comments { for comment in &state.document.comments {
let anchor = &comment.anchor; let anchor = &comment.anchor;
let status = if diff.review_anchor_current(anchor) { let mapped = diff.review_anchor_range(anchor);
let range = mapped.clone().unwrap_or(anchor.start..anchor.end);
let status = if mapped.is_some() {
"current" "current"
} else if diff.comparison.as_ref().is_some_and(|comparison| { } else if diff.comparison.as_ref().is_some_and(|comparison| {
comparison.revisions[anchor.side.index()] == anchor.revision comparison.files.iter().any(|file| {
&& comparison.files.iter().any(|file| {
!file.loaded !file.loaded
&& if anchor.side == Side::Before { && if anchor.side == Side::Before {
file.old_path.as_ref().unwrap_or(&file.path) == &anchor.path file.old_path.as_ref().unwrap_or(&file.path) == &anchor.path
@@ -881,10 +902,10 @@ impl Editor {
"\n## Comment #{} · {resolution} · {status}\n{}:{}:{}–{}:{} · {:?}\nRevision: {}\n{}\n", "\n## Comment #{} · {resolution} · {status}\n{}:{}:{}–{}:{} · {:?}\nRevision: {}\n{}\n",
comment.id, comment.id,
anchor.path.display(), anchor.path.display(),
anchor.start.row + 1, range.start.row + 1,
anchor.start.column + 1, range.start.column + 1,
anchor.end.row + 1, range.end.row + 1,
anchor.end.column + 1, range.end.column + 1,
anchor.side, anchor.side,
anchor.revision, anchor.revision,
comment.body comment.body
+1
View File
@@ -100,6 +100,7 @@ Local reviews: :diff.comment anchors exact Visual text, Visual Line rows, or the
Selected comments highlight their precise text Selected comments highlight their precise text
: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.copy copies all saved comments in CLI format using the configured clipboard
:w saves comment/summary text, including hidden drafts; verdicts/deletions commit immediately :w saves comment/summary text, including hidden drafts; verdicts/deletions commit immediately
:diff.review.save PATH exports JSON; :diff.review.open [ID|PATH] selects/imports; :diff.review.new :diff.review.save PATH exports JSON; :diff.review.open [ID|PATH] selects/imports; :diff.review.new
Normal Esc or :pane.close discards unsaved text and dismisses the floating editor Normal Esc or :pane.close discards unsaved text and dismisses the floating editor
+221 -9
View File
@@ -1,7 +1,7 @@
//! Read-only active-repository discovery and isolated revision comparison. //! Read-only active-repository discovery and isolated revision comparison.
use crate::diff::{ use crate::diff::{
Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus, PullRequestPage, BlameRevision, Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus,
RepoKind, Revision, RevisionTarget, line_hunks, PullRequestPage, RepoKind, Revision, RevisionTarget, line_hunks,
}; };
use std::{ use std::{
collections::{BTreeMap, BTreeSet}, collections::{BTreeMap, BTreeSet},
@@ -28,7 +28,8 @@ const WORKING_SNAPSHOT_LIMIT: usize = 512 * 1024 * 1024;
const TIMEOUT: Duration = Duration::from_secs(60); const TIMEOUT: Duration = Duration::from_secs(60);
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
struct Temporary(PathBuf); #[derive(Debug, PartialEq, Eq)]
pub(crate) struct Temporary(PathBuf);
impl Temporary { impl Temporary {
fn new() -> Result<Self, String> { fn new() -> Result<Self, String> {
for _ in 0..100 { for _ in 0..100 {
@@ -1054,6 +1055,7 @@ struct PreparedComparison {
selected: [Revision; 2], selected: [Revision; 2],
selected_ids: [Option<String>; 2], selected_ids: [Option<String>; 2],
revisions: [String; 2], revisions: [String; 2],
blame: [Option<BlameRevision>; 2],
mode: ComparisonMode, mode: ComparisonMode,
left: Snapshot, left: Snapshot,
right: Snapshot, right: Snapshot,
@@ -1077,7 +1079,7 @@ pub struct Repository {
root: PathBuf, root: PathBuf,
kind: RepoKind, kind: RepoKind,
store: PathBuf, store: PathBuf,
temporary: Temporary, temporary: Arc<Temporary>,
private: Option<(PathBuf, PathBuf)>, // workspace and Git object store private: Option<(PathBuf, PathBuf)>, // workspace and Git object store
remotes: Vec<Remote>, remotes: Vec<Remote>,
remote_refs: BTreeMap<(String, String), String>, remote_refs: BTreeMap<(String, String), String>,
@@ -1128,7 +1130,7 @@ impl Repository {
root, root,
kind, kind,
store, store,
temporary: Temporary::new()?, temporary: Arc::new(Temporary::new()?),
private: None, private: None,
remotes: vec![], remotes: vec![],
remote_refs: BTreeMap::new(), remote_refs: BTreeMap::new(),
@@ -2390,6 +2392,48 @@ impl Repository {
} }
Ok(Some(text(checked(output)?)?.trim().into())) Ok(Some(text(checked(output)?)?.trim().into()))
} }
fn blame_baseline(&mut self, target: &RevisionTarget) -> Result<Option<BlameRevision>, String> {
let source = if *target == RevisionTarget::Working {
let id = if self.kind == RepoKind::Git {
self.working_anchor()?
} else {
let parents = text(self.jj(
&self.root,
&[
"log",
"--no-graph",
"-r",
"@-",
"-T",
"commit_id ++ \"\\n\"",
],
)?)?;
let mut parents = parents.lines();
let parent = parents.next();
// A merge has no single attribution baseline.
if parents.next().is_some() {
None
} else {
parent.map(str::to_owned)
}
};
id.map(|id| (self.store.clone(), id))
} else {
Some(self.resolve(target)?)
};
Ok(source
.filter(|(_, id)| !id.bytes().all(|byte| byte == b'0'))
.map(|(store, commit)| BlameRevision {
keepalive: self
.private
.as_ref()
.filter(|(_, private_store)| private_store == &store)
.map(|_| Arc::clone(&self.temporary)),
store,
commit,
}))
}
fn working_advanced(&self, before: Option<&str>, after: Option<&str>) -> Result<bool, String> { fn working_advanced(&self, before: Option<&str>, after: Option<&str>) -> Result<bool, String> {
if before == after || after.is_none() { if before == after || after.is_none() {
return Ok(false); return Ok(false);
@@ -2492,11 +2536,16 @@ impl Repository {
} else { } else {
self.snapshot_revision(after_target, &right)? self.snapshot_revision(after_target, &right)?
}; };
let blame = [
self.blame_baseline(before_target)?,
self.blame_baseline(after_target)?,
];
let mut prepared = PreparedComparison { let mut prepared = PreparedComparison {
id: comparison_id, id: comparison_id,
selected: [before.clone(), after.clone()], selected: [before.clone(), after.clone()],
selected_ids, selected_ids,
revisions: [before_revision, after_revision], revisions: [before_revision, after_revision],
blame,
mode, mode,
left, left,
right, right,
@@ -2614,10 +2663,12 @@ impl Repository {
} }
let right = self.working_snapshot(buffers)?; let right = self.working_snapshot(buffers)?;
let revision = self.snapshot_revision(&RevisionTarget::Working, &right)?; let revision = self.snapshot_revision(&RevisionTarget::Working, &right)?;
let blame = self.blame_baseline(&RevisionTarget::Working)?;
let mut prepared = self.comparison.take().unwrap(); let mut prepared = self.comparison.take().unwrap();
let previous_right = std::mem::replace(&mut prepared.right, right); let previous_right = std::mem::replace(&mut prepared.right, right);
let previous_files = std::mem::take(&mut prepared.files); let previous_files = std::mem::take(&mut prepared.files);
let previous_revision = std::mem::replace(&mut prepared.revisions[1], revision); let previous_revision = std::mem::replace(&mut prepared.revisions[1], revision);
let previous_blame = std::mem::replace(&mut prepared.blame[1], blame);
let result = self let result = self
.build_comparison(&mut prepared, false) .build_comparison(&mut prepared, false)
.and_then(|comparison| { .and_then(|comparison| {
@@ -2640,6 +2691,7 @@ impl Repository {
prepared.right = previous_right; prepared.right = previous_right;
prepared.files = previous_files; prepared.files = previous_files;
prepared.revisions[1] = previous_revision; prepared.revisions[1] = previous_revision;
prepared.blame[1] = previous_blame;
} }
self.comparison = Some(prepared); self.comparison = Some(prepared);
result.map(|comparison| Some(CheckoutRefresh::Checkout(comparison))) result.map(|comparison| Some(CheckoutRefresh::Checkout(comparison)))
@@ -2671,13 +2723,16 @@ impl Repository {
&& follow_working && follow_working
&& self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())?; && self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())?;
let mut revisions = prepared.revisions.clone(); let mut revisions = prepared.revisions.clone();
let mut blame = prepared.blame.clone();
let (left, before_label) = if branch { let (left, before_label) = if branch {
let base = self.merge_base(ids[0].as_deref().unwrap(), ids[1].as_deref().unwrap())?; let base = self.merge_base(ids[0].as_deref().unwrap(), ids[1].as_deref().unwrap())?;
let label = format!("merge base of {label} and {after_label} · {base}"); let label = format!("merge base of {label} and {after_label} · {base}");
revisions[0] = base.clone(); revisions[0] = base.clone();
blame[0] = self.blame_baseline(&RevisionTarget::Commit(base.clone()))?;
(Some(self.snapshot(&RevisionTarget::Commit(base))?), label) (Some(self.snapshot(&RevisionTarget::Commit(base))?), label)
} else if changed_before { } else if changed_before {
revisions[0] = ids[0].clone().unwrap(); revisions[0] = ids[0].clone().unwrap();
blame[0] = self.blame_baseline(&RevisionTarget::Commit(revisions[0].clone()))?;
( (
Some(self.snapshot(&RevisionTarget::Commit(ids[0].clone().unwrap()))?), Some(self.snapshot(&RevisionTarget::Commit(ids[0].clone().unwrap()))?),
label, label,
@@ -2699,6 +2754,13 @@ impl Repository {
ids[1].clone().unwrap() ids[1].clone().unwrap()
}; };
} }
if right.is_some() {
blame[1] = self.blame_baseline(&if advance_working {
RevisionTarget::Working
} else {
RevisionTarget::Commit(revisions[1].clone())
})?;
}
let pinned_checkout = ids[1] let pinned_checkout = ids[1]
.as_deref() .as_deref()
.map(|id| self.pinned_checkout(id)) .map(|id| self.pinned_checkout(id))
@@ -2711,6 +2773,7 @@ impl Repository {
let previous_before = std::mem::replace(&mut prepared.before, before_label); let previous_before = std::mem::replace(&mut prepared.before, before_label);
let previous_editable = prepared.editable_after; let previous_editable = prepared.editable_after;
let previous_revisions = std::mem::replace(&mut prepared.revisions, revisions); let previous_revisions = std::mem::replace(&mut prepared.revisions, revisions);
let previous_blame = std::mem::replace(&mut prepared.blame, blame);
prepared.editable_after = prepared.editable_after =
working_after || (pinned_checkout.is_some() && pinned_checkout == anchor); working_after || (pinned_checkout.is_some() && pinned_checkout == anchor);
let result = self let result = self
@@ -2747,13 +2810,14 @@ impl Repository {
prepared.before = previous_before; prepared.before = previous_before;
prepared.editable_after = previous_editable; prepared.editable_after = previous_editable;
prepared.revisions = previous_revisions; prepared.revisions = previous_revisions;
prepared.blame = previous_blame;
} }
self.comparison = Some(prepared); self.comparison = Some(prepared);
result result
} }
fn build_comparison( fn build_comparison(
&self, &mut self,
prepared: &mut PreparedComparison, prepared: &mut PreparedComparison,
same: bool, same: bool,
) -> Result<Comparison, String> { ) -> Result<Comparison, String> {
@@ -2770,6 +2834,8 @@ impl Repository {
continue; continue;
} }
files.push(ChangedFile { files.push(ChangedFile {
review_revisions: None,
stats: None,
path: path.clone(), path: path.clone(),
old_path: None, old_path: None,
status: if a.is_none() { status: if a.is_none() {
@@ -2822,21 +2888,36 @@ impl Repository {
Ok(self.comparison_catalog(prepared)) Ok(self.comparison_catalog(prepared))
} }
fn comparison_catalog(&self, prepared: &PreparedComparison) -> Comparison { fn comparison_catalog(&mut self, prepared: &PreparedComparison) -> Comparison {
let stats = if prepared.files.is_empty() {
Ok(Vec::new())
} else {
self.ensure_private()
.and_then(|()| self.comparison_stats(prepared))
};
let files = prepared let files = prepared
.files .files
.iter() .iter()
.map(|(path, old_path, status)| ChangedFile { .enumerate()
.map(|(index, (path, old_path, status))| ChangedFile {
review_revisions: Some([
review_file_revision(prepared.left.get(old_path.as_ref().unwrap_or(path))),
review_file_revision(prepared.right.get(path)),
]),
path: path.clone(), path: path.clone(),
old_path: old_path.clone(), old_path: old_path.clone(),
status: *status, status: *status,
loaded: false, loaded: false,
stats: stats.as_ref().ok().and_then(|stats| stats[index]),
before: vec![], before: vec![],
after: vec![], after: vec![],
before_eof: false, before_eof: false,
after_eof: false, after_eof: false,
hunks: vec![], hunks: vec![],
notice: None, notice: stats
.as_ref()
.err()
.map(|error| format!("Line statistics unavailable: {error}")),
}) })
.collect(); .collect();
let editable_paths = if prepared.editable_after { let editable_paths = if prepared.editable_after {
@@ -2856,6 +2937,7 @@ impl Repository {
}; };
Comparison { Comparison {
revisions: prepared.revisions.clone(), revisions: prepared.revisions.clone(),
blame: prepared.blame.clone(),
before: prepared.before.clone(), before: prepared.before.clone(),
after: prepared.after.clone(), after: prepared.after.clone(),
files, files,
@@ -2864,6 +2946,125 @@ impl Repository {
} }
} }
fn comparison_stats(
&self,
prepared: &PreparedComparison,
) -> Result<Vec<Option<(usize, usize)>>, String> {
// ponytail: unopened counts use Git's text/Myers rules; share the renderer's
// counter if exact binary-detection and pathological-diff parity is needed.
let store = &self.private.as_ref().unwrap().1;
let sides: Vec<_> = prepared
.files
.iter()
.map(|(path, old_path, _)| {
[
prepared.left.get(old_path.as_ref().unwrap_or(path)),
prepared.right.get(path),
]
})
.collect();
let objects: BTreeSet<_> = sides
.iter()
.flatten()
.flatten()
.filter(|file| file.projection.is_none())
.filter_map(|file| file.object.as_ref().map(|(_, id)| id.as_str()))
.collect();
let mut sizes = BTreeMap::new();
if !objects.is_empty() {
let mut input = Vec::new();
for id in objects {
writeln!(&mut input, "{id}").unwrap();
}
let mut command = git_command(store);
command.arg("cat-file").arg("--batch-check");
let output = text(checked(run(command, input, &self.cancelled)?)?)?;
for line in output.lines() {
let fields: Vec<_> = line.split(' ').collect();
if fields.len() != 3 || fields[1] != "blob" {
return Err("Invalid statistics blob metadata".into());
}
let size = fields[2]
.parse::<usize>()
.map_err(|_| "Invalid statistics blob size")?;
sizes.insert(fields[0].to_owned(), size);
}
}
let mut stats = vec![Some((0, 0)); sides.len()];
let mut trees = [Vec::new(), Vec::new()];
for (index, files) in sides.iter().enumerate() {
self.check_cancelled()?;
if files.iter().flatten().any(|file| {
file.projection.is_some()
|| file.object.as_ref().is_some_and(|(_, id)| {
sizes
.get(id)
.is_none_or(|size| *size > COMPARISON_FILE_LIMIT)
})
}) {
stats[index] = None;
continue;
}
for (side, file) in files.iter().enumerate() {
if let Some((_, id)) = file.and_then(|file| file.object.as_ref()) {
// Flat synthetic paths avoid attributes, quoting and rename detection.
// Normalize modes: the renderer compares link targets as ordinary text.
write!(&mut trees[side], "100644 blob {id}\t{index}\0").unwrap();
}
}
}
if trees.iter().all(Vec::is_empty) {
return Ok(stats);
}
let mut ids = Vec::new();
for tree in trees {
let mut command = git_command(store);
command.args(["mktree", "-z"]);
ids.push(text(checked(run(command, tree, &self.cancelled)?)?)?);
}
let mut command = git_command(store);
command
.args(["-c", "core.attributesFile=/dev/null"])
.args([
"diff-tree",
"--numstat",
"-z",
"--no-renames",
"--no-ext-diff",
"--no-textconv",
"--diff-algorithm=myers",
"--no-indent-heuristic",
])
.args(ids.iter().map(|id| id.trim()))
.env("GIT_ATTR_NOSYSTEM", "1")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null");
let output = checked(run(command, Vec::new(), &self.cancelled)?)?;
for record in output.split(|byte| *byte == 0).filter(|r| !r.is_empty()) {
let record = std::str::from_utf8(record).map_err(|_| "Invalid line statistics")?;
let fields: Vec<_> = record.split('\t').collect();
if fields.len() != 3 {
return Err("Invalid line statistics".into());
}
let index = fields[2]
.parse::<usize>()
.ok()
.filter(|index| *index < stats.len())
.ok_or("Invalid line statistics index")?;
stats[index] = Some(if fields[0] == "-" && fields[1] == "-" {
(0, 0)
} else {
(
fields[0].parse().map_err(|_| "Invalid added line count")?,
fields[1]
.parse()
.map_err(|_| "Invalid removed line count")?,
)
});
}
Ok(stats)
}
/// Prepare one catalog entry using its original comparison's frozen sources. /// Prepare one catalog entry using its original comparison's frozen sources.
pub fn load_file( pub fn load_file(
&mut self, &mut self,
@@ -2902,6 +3103,7 @@ impl Repository {
)?; )?;
file.status = *status; file.status = *status;
file.old_path = old_path.clone(); file.old_path = old_path.clone();
file.review_revisions = Some([review_file_revision(before), review_file_revision(after)]);
Ok(file) Ok(file)
} }
fn changed_file( fn changed_file(
@@ -2972,6 +3174,8 @@ impl Repository {
}; };
notices.dedup(); notices.dedup();
Ok(ChangedFile { Ok(ChangedFile {
review_revisions: None,
stats: None,
path: path.to_path_buf(), path: path.to_path_buf(),
old_path: None, old_path: None,
status, status,
@@ -2990,6 +3194,14 @@ impl Repository {
} }
} }
fn review_file_revision(file: Option<&FileData>) -> String {
// Git object IDs cover exact bytes (including binary data and EOF), without loading blobs.
format!(
"file:{}",
serde_json::to_string(&file.map(file_identity)).unwrap()
)
}
fn file_identity(file: &FileData) -> (&str, Option<&str>, &[u8], Option<&str>) { fn file_identity(file: &FileData) -> (&str, Option<&str>, &[u8], Option<&str>) {
( (
&file.mode, &file.mode,
+168 -7
View File
@@ -4,6 +4,7 @@ use std::{
cell::RefCell, cell::RefCell,
collections::BTreeSet, collections::BTreeSet,
ffi::OsStr, ffi::OsStr,
fmt::Write as _,
fs::{self, OpenOptions}, fs::{self, OpenOptions},
io::{Read, Write}, io::{Read, Write},
path::{Path, PathBuf}, path::{Path, PathBuf},
@@ -59,21 +60,115 @@ impl Anchor {
self.start.row..=end self.start.row..=end
} }
pub fn matches_lines( pub fn excerpt(&self) -> &str {
let offset = |position: crate::plugin::TextPosition| {
let line = self.content.split('\n').nth(position.row).unwrap_or("");
self.content
.split('\n')
.take(position.row)
.map(|line| line.len() + 1)
.sum::<usize>()
+ line
.grapheme_indices(true)
.nth(position.column)
.map_or(line.len(), |(index, _)| index)
};
&self.content[offset(self.start)..offset(self.end)]
}
/// Map an unchanged selection through edits without changing its saved snapshot.
pub fn mapped_range(
&self, &self,
revision: &str, revision: &str,
path: &Path, path: &Path,
side: Side, side: Side,
lines: &[String], lines: &[String],
eof: bool, eof: bool,
) -> bool { ) -> Option<std::ops::Range<crate::plugin::TextPosition>> {
self.revision == revision use crate::plugin::TextPosition;
&& self.path == path if self.path != path
&& self.side == side || self.side != side
&& self.content.split('\n').eq(lines || (self.revision != revision
&& !(self.revision.starts_with("working:") && revision.starts_with("working:")))
{
return None;
}
if self.content.split('\n').eq(lines
.iter() .iter()
.map(String::as_str) .map(String::as_str)
.chain((eof || lines.is_empty()).then_some(""))) .chain((eof || lines.is_empty()).then_some("")))
{
return Some(self.start..self.end);
}
let mut content = lines.join("\n");
if eof && !lines.is_empty() {
content.push('\n');
}
// Split LF explicitly: editor columns count a retained CR separately.
fn tokens(text: &str) -> Vec<&str> {
text.split_inclusive('\n')
.flat_map(|line| {
line.strip_suffix('\n')
.unwrap_or(line)
.graphemes(true)
.chain(line.ends_with('\n').then_some("\n"))
})
.collect()
}
let before = tokens(&self.content);
let after = tokens(&content);
let index = |position: TextPosition| {
self.content
.split('\n')
.take(position.row)
.map(|line| line.graphemes(true).count() + 1)
.sum::<usize>()
+ position.column
};
let start = index(self.start);
let end = index(self.end);
if start == end {
return None;
}
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(10);
let (mut old, mut new) = (0, 0);
for op in similar::capture_diff_slices_deadline(
similar::Algorithm::Myers,
&before,
&after,
Some(deadline),
) {
let (tag, old_range, new_range) = op.as_tag_tuple();
let old_end = old + old_range.len();
if tag == similar::DiffTag::Equal && start >= old && end <= old_end {
// Use the unchanged context, not just potentially repeated selected text.
let byte_start: usize = before[..old].iter().map(|text| text.len()).sum();
let byte_len: usize = before[old..old_end].iter().map(|text| text.len()).sum();
let context = &self.content[byte_start..byte_start + byte_len];
if self.content.match_indices(context).take(2).count() != 1
|| content.match_indices(context).take(2).count() != 1
{
return None;
}
let mapped = new + start - old;
let position = |offset: usize| {
let mut position = TextPosition { row: 0, column: 0 };
for grapheme in &after[..offset] {
if *grapheme == "\n" {
position.row += 1;
position.column = 0;
} else {
position.column += 1;
}
}
position
};
return Some(position(mapped)..position(mapped + end - start));
}
old = old_end;
new += new_range.len();
}
None
} }
} }
@@ -148,6 +243,69 @@ impl Review {
} }
} }
pub fn summary_text(&self) -> String {
let resolved = self
.comments
.iter()
.filter(|comment| comment.resolved)
.count();
format!(
"Review ID: {}{}{}\n Verdict: {}; {} unresolved, {} resolved ({} total)\n",
self.storage_id
.map_or_else(|| "null".into(), |id| id.to_string()),
if self.summary.is_empty() { "" } else { ": " },
self.summary,
match self.verdict {
Some(Verdict::LooksGood) => "looks_good",
Some(Verdict::ChangesRequested) => "changes_requested",
None => "none",
},
self.comments.len() - resolved,
resolved,
self.comments.len(),
)
}
/// CLI show format, including the repository, summary, and selected comments.
pub fn format_text(&self, all: bool) -> String {
let mut text = format!(
"Repository: {}\n{}",
self.root.display(),
self.summary_text()
);
for comment in self
.comments
.iter()
.filter(|comment| all || !comment.resolved)
{
let anchor = &comment.anchor;
writeln!(
text,
"\nComment {} [{}] {} ({}) {}:{}–{}:{} @ {}\n{}\n{}",
comment.id,
if comment.resolved {
"resolved"
} else {
"unresolved"
},
anchor.path.display(),
match anchor.side {
Side::Before => "before",
Side::After => "after",
},
anchor.start.row,
anchor.start.column,
anchor.end.row,
anchor.end.column,
anchor.revision,
anchor.excerpt(),
comment.body,
)
.unwrap();
}
text
}
fn validate(&self) -> Result<(), String> { fn validate(&self) -> Result<(), String> {
if self.version != 4 { if self.version != 4 {
return Err(format!("Unsupported review version {}", self.version)); return Err(format!("Unsupported review version {}", self.version));
@@ -530,7 +688,10 @@ impl Store {
let row = rows let row = rows
.next() .next()
.map_err(database_error)? .map_err(database_error)?
.ok_or("Review does not exist in this repository")?; .ok_or_else(|| format!(
"Review ID {id} not found in {}.\nRun `vamp --review list` to find review IDs for this repository.",
root.display()
))?;
stored_review(&transaction, row, &root)? stored_review(&transaction, row, &root)?
}; };
transaction.commit().map_err(database_error)?; transaction.commit().map_err(database_error)?;
+33 -73
View File
@@ -3,10 +3,9 @@ use std::{
ffi::{OsStr, OsString}, ffi::{OsStr, OsString},
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
use unicode_segmentation::UnicodeSegmentation;
use vamp::{ use vamp::{
repository, repository,
review::{Anchor, Review, Store}, review::{Review, Store},
ruby, ruby,
}; };
@@ -25,7 +24,11 @@ impl Request {
let id = if command == "list" { let id = if command == "list" {
None None
} else if command == "show" || command == "delete" { } else if command == "show" || command == "delete" {
let error = "show/delete requires a positive review ID"; let error = if command == "show" {
"`show` requires a positive review ID.\nUsage: vamp --review show ID [--all]\n`--all` includes resolved comments; it does not select all reviews."
} else {
"`delete` requires a positive review ID.\nUsage: vamp --review delete REVIEW_ID COMMENT_ID"
};
let id = args.next().ok_or(error)?; let id = args.next().ok_or(error)?;
Some( Some(
id.to_str() id.to_str()
@@ -79,24 +82,6 @@ impl Request {
} }
} }
fn excerpt(anchor: &Anchor) -> String {
// Positions use extended graphemes, not bytes or Unicode scalar values.
let offset = |position: vamp::plugin::TextPosition| {
let line = anchor.content.split('\n').nth(position.row).unwrap_or("");
anchor
.content
.split('\n')
.take(position.row)
.map(|line| line.len() + 1)
.sum::<usize>()
+ line
.grapheme_indices(true)
.nth(position.column)
.map_or(line.len(), |(index, _)| index)
};
anchor.content[offset(anchor.start)..offset(anchor.end)].to_owned()
}
fn envelope(review: &Review, all: bool, comments: bool) -> Value { fn envelope(review: &Review, all: bool, comments: bool) -> Value {
let resolved = review let resolved = review
.comments .comments
@@ -116,7 +101,7 @@ fn envelope(review: &Review, all: bool, comments: bool) -> Value {
json!({"id": comment.id, "body": comment.body, "resolved": comment.resolved, json!({"id": comment.id, "body": comment.body, "resolved": comment.resolved,
"anchor": { "revision": comment.anchor.revision, "path": comment.anchor.path, "anchor": { "revision": comment.anchor.revision, "path": comment.anchor.path,
"side": comment.anchor.side, "start": comment.anchor.start, "end": comment.anchor.end }, "side": comment.anchor.side, "start": comment.anchor.start, "end": comment.anchor.end },
"excerpt": excerpt(&comment.anchor)}) "excerpt": comment.anchor.excerpt()})
}).collect(); }).collect();
} }
value value
@@ -131,10 +116,13 @@ pub fn run(request: &Request, config: Option<&Path>) -> Result<(), String> {
let cwd = std::env::current_dir().map_err(|error| error.to_string())?; let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
let root = repository::discover_root(request.repo.as_deref().unwrap_or(&cwd))?; let root = repository::discover_root(request.repo.as_deref().unwrap_or(&cwd))?;
let store = Store::open_read_only(&directory)?; let store = Store::open_read_only(&directory)?;
let output = if let Some(id) = request.id { let reviews = if let Some(id) = request.id {
let mut review = store let mut review = store
.as_ref() .as_ref()
.ok_or("Review does not exist in this repository")? .ok_or_else(|| format!(
"Review ID {id} not found in {}.\nRun `vamp --review list` to find review IDs for this repository.",
root.display()
))?
.load(&root, id)?; .load(&root, id)?;
if let Some(comment_id) = request.delete_comment { if let Some(comment_id) = request.delete_comment {
let index = review let index = review
@@ -145,76 +133,48 @@ pub fn run(request: &Request, config: Option<&Path>) -> Result<(), String> {
review.comments.remove(index); review.comments.remove(index);
Store::open(&directory)?.save(&mut review)?; Store::open(&directory)?.save(&mut review)?;
} }
envelope(&review, request.all, true) vec![review]
} else { } else {
let reviews = match store { match store {
Some(store) => store Some(store) => store.list(&root)?,
.list(&root)?
.into_iter()
.map(|review| envelope(&review, true, false))
.collect::<Vec<_>>(),
None => Vec::new(), None => Vec::new(),
}; }
json!({"repository": root, "reviews": reviews})
}; };
if request.json { if request.json {
let output = if request.id.is_some() {
envelope(&reviews[0], request.all, true)
} else {
let reviews = reviews
.iter()
.map(|review| envelope(review, true, false))
.collect::<Vec<_>>();
json!({"repository": root, "reviews": reviews})
};
println!( println!(
"{}", "{}",
serde_json::to_string_pretty(&output).map_err(|error| error.to_string())? serde_json::to_string_pretty(&output).map_err(|error| error.to_string())?
); );
} else if request.id.is_some() {
print!("{}", reviews[0].format_text(request.all));
} else { } else {
println!("Repository: {}", root.display()); println!("Repository: {}", root.display());
if let Some(reviews) = output["reviews"].as_array() {
if reviews.is_empty() { if reviews.is_empty() {
println!("No stored reviews."); println!("No stored reviews.");
} }
for review in reviews { for review in &reviews {
print_review(review); print!("{}", review.summary_text());
}
} else {
print_review(&output);
for comment in output["comments"].as_array().unwrap() {
let anchor = &comment["anchor"];
println!( println!(
"\nComment {} [{}] {} ({}) {}:{}–{}:{} @ {}", "\nShow comments: vamp --review show {}",
comment["id"], review.storage_id.unwrap()
if comment["resolved"] == true {
"resolved"
} else {
"unresolved"
},
anchor["path"].as_str().unwrap(),
anchor["side"].as_str().unwrap(),
anchor["start"]["row"],
anchor["start"]["column"],
anchor["end"]["row"],
anchor["end"]["column"],
anchor["revision"].as_str().unwrap()
);
println!(
"{}\n{}",
comment["excerpt"].as_str().unwrap(),
comment["body"].as_str().unwrap()
); );
} }
if !reviews.is_empty() {
println!("Include resolved comments: add --all");
} }
} }
Ok(()) Ok(())
} }
fn print_review(review: &Value) {
println!(
"Review {} (revision {}): {}\n Verdict: {}; {} unresolved, {} resolved ({} total)",
review["id"],
review["revision"],
review["summary"].as_str().unwrap(),
review["verdict"].as_str().unwrap_or("none"),
review["counts"]["unresolved"],
review["counts"]["resolved"],
review["counts"]["total"]
);
}
#[cfg(test)] #[cfg(test)]
#[path = "../tests/unit/review_cli.rs"] #[path = "../tests/unit/review_cli.rs"]
mod tests; mod tests;
+1
View File
@@ -1690,6 +1690,7 @@ impl EditorSession {
self.blame_request_id, self.blame_request_id,
source.path.to_path_buf(), source.path.to_path_buf(),
self.editor.lines().join("\n"), self.editor.lines().join("\n"),
source.snapshot.as_deref().cloned(),
) )
.is_err() .is_err()
{ {
+91 -42
View File
@@ -1643,8 +1643,8 @@ fn chrome_title(
}; };
match field { match field {
"index" => write!(title, "{index}").unwrap(), "index" => write!(title, "{index}").unwrap(),
"view" if diff.is_some() => { "view" if diff.is_some() => title.push_str("Diff · "),
title.push_str("Diff · "); "changes" => {
if let Some(diff) = diff if let Some(diff) = diff
&& diff.phase == Phase::View && diff.phase == Phase::View
&& let Some(file) = diff && let Some(file) = diff
@@ -1652,12 +1652,9 @@ fn chrome_title(
.as_ref() .as_ref()
.and_then(|comparison| comparison.files.get(diff.file)) .and_then(|comparison| comparison.files.get(diff.file))
&& file.loaded && file.loaded
&& let Some((added, removed)) = file.line_counts()
{ {
let (added, removed) = write!(title, " +{added} -{removed}").unwrap();
file.hunks.iter().fold((0, 0), |(added, removed), hunk| {
(added + hunk.after_len, removed + hunk.before_len)
});
write!(title, "+{added} -{removed} · ").unwrap();
} }
} }
"name" => title.push_str(&name), "name" => title.push_str(&name),
@@ -1783,6 +1780,24 @@ fn chrome_items<'a>(
} else { } else {
String::new() String::new()
}), }),
"diff" => {
let Some(comparison) = editor
.workspace_diff()
.filter(|diff| diff.phase == Phase::View)
.and_then(|diff| diff.comparison.as_ref())
else {
continue;
};
match comparison
.files
.iter()
.try_fold((0, 0), |(added, removed), file| {
file.line_counts().map(|(a, r)| (added + a, removed + r))
}) {
Some((added, removed)) => Cow::Owned(format!("Diff +{added} -{removed}")),
None => Cow::Borrowed("Diff …"),
}
}
"battery" if editor.label(name).is_empty() => Cow::Borrowed(editor.battery_label()), "battery" if editor.label(name).is_empty() => Cow::Borrowed(editor.battery_label()),
"shell" if editor.label(name).is_empty() => Cow::Borrowed(editor.shell_label()), "shell" if editor.label(name).is_empty() => Cow::Borrowed(editor.shell_label()),
_ => Cow::Borrowed(editor.label(name)), _ => Cow::Borrowed(editor.label(name)),
@@ -2665,22 +2680,13 @@ fn draw_document(
.find(|(_, (row, _, _))| *row == cursor.row) .find(|(_, (row, _, _))| *row == cursor.row)
{ {
let line = &editor.lines()[cursor.row]; let line = &editor.lines()[cursor.row];
let end = display_column(line, usize::MAX); if !wrapped || continuation + 1 == document.wrap.segments(cursor.row, line).len() {
let start = end.saturating_sub(segment.start).saturating_add(2); paint_inline_blame(
let label_width = UnicodeWidthStr::width(label); frame,
if end >= segment.start label,
&& end <= segment.end line,
&& (!wrapped || continuation + 1 == document.wrap.segments(cursor.row, line).len()) Rect::new(viewport.x, viewport.y + screen_row as u16, width as u16, 1),
&& start.saturating_add(label_width) <= width segment.start,
{
frame.render_widget(
Paragraph::new(label).style(Style::default().fg(Color::DarkGray)),
Rect::new(
viewport.x + start as u16,
viewport.y + screen_row as u16,
label_width as u16,
1,
),
); );
} }
} }
@@ -3236,6 +3242,19 @@ fn paint_editor_line(
} }
} }
fn paint_inline_blame(frame: &mut Frame, label: &str, line: &str, area: Rect, left: usize) {
let end = display_column(line, usize::MAX);
let start = end.saturating_sub(left).saturating_add(2);
let label_width =
UnicodeWidthStr::width(label).min(usize::from(area.width).saturating_sub(start));
if end >= left && label_width > 0 {
frame.render_widget(
Paragraph::new(label).style(Style::default().fg(Color::DarkGray)),
Rect::new(area.x + start as u16, area.y, label_width as u16, 1),
);
}
}
fn paint_diff_editor( fn paint_diff_editor(
frame: &mut Frame, frame: &mut Frame,
editor: &Editor, editor: &Editor,
@@ -3250,6 +3269,7 @@ fn paint_diff_editor(
let source_cursor = editor.cursor(); let source_cursor = editor.cursor();
let cursor_column = display_column(&editor.lines()[source_cursor.row], source_cursor.column); let cursor_column = display_column(&editor.lines()[source_cursor.row], source_cursor.column);
let mut cursor = None; let mut cursor = None;
let mut blame_area = None;
let focus = view.info.focus; let focus = view.info.focus;
for (screen, display, segments) in for (screen, display, segments) in
diff_screen_rows(view.info.display_len, top, area.height, wrapped) diff_screen_rows(view.info.display_len, top, area.height, wrapped)
@@ -3283,6 +3303,20 @@ fn paint_diff_editor(
cursor = Some(Position::new(area.x + (column - left) as u16, line_area.y)); cursor = Some(Position::new(area.x + (column - left) as u16, line_area.y));
} }
} }
if source == source_cursor.row
&& (segments.is_none()
|| wrapped.is_some_and(|rows| {
rows[screen].final_segments[usize::from(focus == Focus::After)]
}))
{
blame_area = Some((Rect::new(area.x, line_area.y, area.width, 1), left));
}
}
if cursor.is_some()
&& let Some((area, left)) = blame_area
&& let Some(label) = editor.line_blame()
{
paint_inline_blame(frame, label, &editor.lines()[source_cursor.row], area, left);
} }
cursor cursor
} }
@@ -4332,16 +4366,18 @@ fn draw_diff_pane(
.document .document
.comments .comments
.iter() .iter()
.filter(|comment| { .filter_map(|comment| {
let before = comment.anchor.side == crate::review::Side::Before; let before = comment.anchor.side == crate::review::Side::Before;
let path = if before { let path = if before {
file.old_path.as_ref().unwrap_or(&file.path) file.old_path.as_ref().unwrap_or(&file.path)
} else { } else {
&file.path &file.path
}; };
(unified || before == (focus == Focus::Before)) if !(unified || before == (focus == Focus::Before)) || comment.anchor.path != *path {
&& comment.anchor.path == *path return None;
&& diff.review_anchor_current(&comment.anchor) }
diff.review_anchor_range(&comment.anchor)
.map(|range| (comment, range))
}); });
paint_diff_content( paint_diff_content(
frame, frame,
@@ -4373,7 +4409,12 @@ fn paint_diff_content<'a>(
highlight_trailing_whitespace: bool, highlight_trailing_whitespace: bool,
whitespace_characters: Option<&BTreeMap<char, char>>, whitespace_characters: Option<&BTreeMap<char, char>>,
whitespace_scope: WhitespaceScope, whitespace_scope: WhitespaceScope,
review_comments: impl Iterator<Item = &'a crate::review::Comment> + Clone, review_comments: impl Iterator<
Item = (
&'a crate::review::Comment,
std::ops::Range<crate::plugin::TextPosition>,
),
> + Clone,
selected_review: Option<u64>, selected_review: Option<u64>,
) -> Rect { ) -> Rect {
if view.info.display_len == 0 { if view.info.display_len == 0 {
@@ -4396,8 +4437,7 @@ fn paint_diff_content<'a>(
let width = usize::from(content.width).saturating_sub(gutter); let width = usize::from(content.width).saturating_sub(gutter);
let selected_anchor = review_comments let selected_anchor = review_comments
.clone() .clone()
.find(|comment| selected_review == Some(comment.id)) .find(|(comment, _)| selected_review == Some(comment.id));
.map(|comment| &comment.anchor);
for (screen, display_row, segments) in for (screen, display_row, segments) in
diff_screen_rows(view.info.display_len, top, content.height, wrapped) diff_screen_rows(view.info.display_len, top, content.height, wrapped)
{ {
@@ -4550,22 +4590,24 @@ fn paint_diff_content<'a>(
}; };
paint_trailing_whitespace(frame, &text, text_area, left); paint_trailing_whitespace(frame, &text, text_area, left);
} }
if let Some(anchor) = selected_anchor { if let Some((comment, range)) = &selected_anchor {
let side = match anchor.side { let side = match comment.anchor.side {
crate::review::Side::Before => Focus::Before, crate::review::Side::Before => Focus::Before,
crate::review::Side::After => Focus::After, crate::review::Side::After => Focus::After,
}; };
if let Some(source_row) = view if let Some(source_row) = view.review_row(side, display_row).filter(|source_row| {
.review_row(side, display_row) (range.start.row
.filter(|source_row| anchor.rows().contains(source_row)) ..=range.end.row
{ - usize::from(range.end.column == 0 && range.end.row > range.start.row))
let start = if source_row == anchor.start.row { .contains(source_row)
anchor.start.column }) {
let start = if source_row == range.start.row {
range.start.column
} else { } else {
0 0
}; };
let end = if source_row == anchor.end.row { let end = if source_row == range.end.row {
anchor.end.column range.end.column
} else { } else {
usize::MAX usize::MAX
}; };
@@ -4581,7 +4623,7 @@ fn paint_diff_content<'a>(
} }
} }
if gutter > 0 { if gutter > 0 {
for comment in review_comments { for (comment, range) in review_comments {
let side = match comment.anchor.side { let side = match comment.anchor.side {
crate::review::Side::Before => Focus::Before, crate::review::Side::Before => Focus::Before,
crate::review::Side::After => Focus::After, crate::review::Side::After => Focus::After,
@@ -4592,7 +4634,14 @@ fn paint_diff_content<'a>(
{ {
let source = view.review_row(side, display); let source = view.review_row(side, display);
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| comment.anchor.rows().contains(&row)) || !source.is_some_and(|row| {
(range.start.row
..=range.end.row
- usize::from(
range.end.column == 0 && range.end.row > range.start.row,
))
.contains(&row)
})
{ {
continue; continue;
} }
+162 -1
View File
@@ -226,6 +226,13 @@ fn comparison_revisions_freeze_commits_and_address_working_contents() {
.unwrap(); .unwrap();
assert_eq!(first.revisions[0], id); assert_eq!(first.revisions[0], id);
assert!(first.revisions[1].starts_with("working:")); assert!(first.revisions[1].starts_with("working:"));
assert_eq!(first.blame[0].as_ref().unwrap().commit, id);
assert_eq!(
first.blame[1]
.as_ref()
.map(|baseline| baseline.commit.as_str()),
(kind == RepoKind::Git).then_some(id.as_str())
);
let reopened = open(&root) let reopened = open(&root)
.compare(2, &before, &after, ComparisonMode::Snapshots, &[]) .compare(2, &before, &after, ComparisonMode::Snapshots, &[])
.unwrap(); .unwrap();
@@ -255,6 +262,10 @@ fn comparison_revisions_freeze_commits_and_address_working_contents() {
.unwrap(); .unwrap();
assert_eq!(working_pair.revisions[0], first.revisions[1]); assert_eq!(working_pair.revisions[0], first.revisions[1]);
assert_eq!(working_pair.revisions[0], working_pair.revisions[1]); assert_eq!(working_pair.revisions[0], working_pair.revisions[1]);
assert_eq!(
working_pair.blame,
[first.blame[1].clone(), first.blame[1].clone()]
);
assert_eq!(disk_state(&root), state); assert_eq!(disk_state(&root), state);
fs::write(root.join("file"), "working").unwrap(); fs::write(root.join("file"), "working").unwrap();
let no_newline = repository let no_newline = repository
@@ -264,6 +275,93 @@ fn comparison_revisions_freeze_commits_and_address_working_contents() {
} }
} }
#[test]
fn unopened_catalog_counts_frozen_text_but_not_binary_modes_or_exact_renames() {
let fixture = Fixture::new();
let root = fixture.dir("stats");
init_git(&root);
for (path, bytes) in [
("first", &b"old\nkeep\n"[..]),
("second", b"remove\nkeep\n"),
("removed", b"gone\n"),
("rename-old", b"move\n"),
("binary", b"\0old"),
("mode", b"script\n"),
("crlf", b"a\r\n"),
("eof", b"same\n"),
] {
fs::write(root.join(path), bytes).unwrap();
}
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "before"]);
let before = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git);
for (path, bytes) in [
("first", &b"new\nextra\nkeep\n"[..]),
("second", b"keep\n"),
("added", b"one\nlast"),
("binary", b"\0new"),
("crlf", b"b\r\n"),
("eof", b"same"),
] {
fs::write(root.join(path), bytes).unwrap();
}
fs::remove_file(root.join("removed")).unwrap();
fs::rename(root.join("rename-old"), root.join("-renamed\t\n")).unwrap();
fs::set_permissions(root.join("mode"), fs::Permissions::from_mode(0o755)).unwrap();
let mut repository = open(&root);
let catalog = repository
.compare(
1,
&before,
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert!(catalog.files.iter().all(|file| !file.loaded
&& file.before.is_empty()
&& file.after.is_empty()
&& file.hunks.is_empty()));
for (path, counts) in [
("first", (2, 1)),
("second", (0, 1)),
("added", (2, 0)),
("removed", (0, 1)),
("-renamed\t\n", (0, 0)),
("binary", (0, 0)),
("mode", (0, 0)),
("crlf", (1, 1)),
("eof", (1, 1)),
] {
assert_eq!(
changed(&catalog, Path::new(path)).stats,
Some(counts),
"{path}"
);
}
assert_eq!(
changed(&catalog, Path::new("-renamed\t\n")).status,
FileStatus::Renamed
);
fs::write(root.join("second"), "changed after freezing\n").unwrap();
for (index, file) in catalog.files.iter().enumerate() {
let loaded = repository.load_file(1, index).unwrap();
let counts = loaded.hunks.iter().fold((0, 0), |(added, removed), hunk| {
(added + hunk.after_len, removed + hunk.before_len)
});
assert_eq!(file.stats, Some(counts), "{}", file.path.display());
}
git(&root, &["add", "-A"]);
git(&root, &["commit", "-m", "advance checkout"]);
let refreshed = repository
.refresh_checkout(1, 2, true, &[])
.unwrap()
.map(checkout_comparison)
.unwrap();
assert_eq!(changed(&refreshed, Path::new("first")).stats, Some((2, 1)));
assert_eq!(changed(&refreshed, Path::new("second")).stats, Some((1, 2)));
}
#[test] #[test]
fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_paths() { fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_paths() {
let fixture = Fixture::new(); let fixture = Fixture::new();
@@ -785,6 +883,8 @@ fn jj_bookmark_refresh_tracks_movement_and_same_change_rewrites_without_touching
panic!("Bookmark movement must replace contents even when follow is disabled"); panic!("Bookmark movement must replace contents even when follow is disabled");
}; };
assert_eq!(comparison.after, topic.label); assert_eq!(comparison.after, topic.label);
assert!(comparison.blame[0].is_none());
assert_eq!(comparison.blame[1].as_ref().unwrap().commit, next);
assert_eq!(repository.load_file(2, 0).unwrap().after, ["next"]); assert_eq!(repository.load_file(2, 0).unwrap().after, ["next"]);
assert!(repository.load_file(1, 0).is_err()); assert!(repository.load_file(1, 0).is_err());
assert!( assert!(
@@ -944,6 +1044,8 @@ fn jj_bookmark_refresh_recomputes_branch_merge_base_and_recovers_after_deletion(
.unwrap(); .unwrap();
assert!(original.before.ends_with(&base)); assert!(original.before.ends_with(&base));
assert_eq!(original.revisions, [base.clone(), tip.clone()]); assert_eq!(original.revisions, [base.clone(), tip.clone()]);
assert_eq!(original.blame[0].as_ref().unwrap().commit, base);
assert_eq!(original.blame[1].as_ref().unwrap().commit, tip);
jj(&root, &["bookmark", "set", "before", "-r", &tip]); jj(&root, &["bookmark", "set", "before", "-r", &tip]);
let Some(CheckoutRefresh::Bookmark(comparison)) = let Some(CheckoutRefresh::Bookmark(comparison)) =
repository.refresh_checkout(1, 2, false, &[]).unwrap() repository.refresh_checkout(1, 2, false, &[]).unwrap()
@@ -952,6 +1054,8 @@ fn jj_bookmark_refresh_recomputes_branch_merge_base_and_recovers_after_deletion(
}; };
assert!(comparison.before.ends_with(&tip)); assert!(comparison.before.ends_with(&tip));
assert_eq!(comparison.revisions, [tip.clone(), tip.clone()]); assert_eq!(comparison.revisions, [tip.clone(), tip.clone()]);
assert_eq!(comparison.blame[0].as_ref().unwrap().commit, tip);
assert_eq!(comparison.blame[1].as_ref().unwrap().commit, tip);
assert!(comparison.files.is_empty()); assert!(comparison.files.is_empty());
jj(&root, &["bookmark", "delete", "after"]); jj(&root, &["bookmark", "delete", "after"]);
let state = disk_state(&root); let state = disk_state(&root);
@@ -1331,6 +1435,7 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
.unwrap(); .unwrap();
assert_eq!(promoted.before, branch.before); assert_eq!(promoted.before, branch.before);
assert_eq!(promoted.after, branch.after); assert_eq!(promoted.after, branch.after);
assert_eq!(promoted.blame, branch.blame);
assert!(promoted.editable_paths.contains(Path::new("file"))); assert!(promoted.editable_paths.contains(Path::new("file")));
assert_eq!(repository.load_file(62, 0).unwrap().after, ["remote tip"]); assert_eq!(repository.load_file(62, 0).unwrap().after, ["remote tip"]);
assert_eq!(disk_state(&active), checked_out_state); assert_eq!(disk_state(&active), checked_out_state);
@@ -1692,6 +1797,8 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable
&[], &[],
) )
.unwrap(); .unwrap();
assert_eq!(changed(&branch, Path::new("conflict")).stats, None);
assert_eq!(changed(&branch, Path::new("ordinary")).stats, Some((1, 1)));
let index = branch let index = branch
.files .files
.iter() .iter()
@@ -1985,6 +2092,8 @@ fn comparison_size_and_line_ceilings_are_lazy_and_explicit() {
.unwrap(); .unwrap();
assert_eq!(catalog.files.len(), 3); assert_eq!(catalog.files.len(), 3);
assert!(catalog.files.iter().all(|file| !file.loaded)); assert!(catalog.files.iter().all(|file| !file.loaded));
assert_eq!(changed(&catalog, Path::new("too-many-bytes")).stats, None);
assert_eq!(changed(&catalog, Path::new("selected")).stats, Some((1, 0)));
for (index, entry) in catalog.files.iter().enumerate() { for (index, entry) in catalog.files.iter().enumerate() {
match entry.path.to_str().unwrap() { match entry.path.to_str().unwrap() {
"selected" => assert_eq!( "selected" => assert_eq!(
@@ -2260,6 +2369,8 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
.unwrap(); .unwrap();
assert_eq!(refreshed.before, original.before); assert_eq!(refreshed.before, original.before);
assert_eq!(refreshed.after, original.after); assert_eq!(refreshed.after, original.after);
assert_eq!(refreshed.blame[0], original.blame[0]);
assert_eq!(refreshed.blame[1].as_ref().unwrap().commit, tip);
for (index, file) in refreshed.files.iter_mut().enumerate() { for (index, file) in refreshed.files.iter_mut().enumerate() {
*file = repository.load_file(2, index).unwrap(); *file = repository.load_file(2, index).unwrap();
} }
@@ -2526,13 +2637,14 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
jj(&root, &["new"]); jj(&root, &["new"]);
fs::write(root.join("file"), "original disk\n").unwrap(); fs::write(root.join("file"), "original disk\n").unwrap();
let mut repository = open(&root); let mut repository = open(&root);
compare_loaded( let initial = compare_loaded(
&mut repository, &mut repository,
&revision(&base, RepoKind::Jujutsu), &revision(&base, RepoKind::Jujutsu),
&working(RepoKind::Jujutsu), &working(RepoKind::Jujutsu),
&[], &[],
) )
.unwrap(); .unwrap();
assert_eq!(initial.blame[1].as_ref().unwrap().commit, base);
fs::write(root.join("file"), "same-change rewrite\n").unwrap(); fs::write(root.join("file"), "same-change rewrite\n").unwrap();
jj(&root, &["describe", "-m", "rewritten"]); jj(&root, &["describe", "-m", "rewritten"]);
let state = disk_state(&root); let state = disk_state(&root);
@@ -2545,6 +2657,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]); assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]);
assert_eq!(disk_state(&root), state); assert_eq!(disk_state(&root), state);
jj(&root, &["new"]); jj(&root, &["new"]);
let parent = jj(&root, &["log", "--no-graph", "-r", "@-", "-T", "commit_id"]);
fs::remove_file(root.join("removed")).unwrap(); fs::remove_file(root.join("removed")).unwrap();
fs::write(root.join("added"), "new file\n").unwrap(); fs::write(root.join("added"), "new file\n").unwrap();
let state = disk_state(&root); let state = disk_state(&root);
@@ -2553,6 +2666,8 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
.unwrap() .unwrap()
.map(checkout_comparison) .map(checkout_comparison)
.unwrap(); .unwrap();
assert_eq!(comparison.blame[0], initial.blame[0]);
assert_eq!(comparison.blame[1].as_ref().unwrap().commit, parent);
for (index, file) in comparison.files.iter_mut().enumerate() { for (index, file) in comparison.files.iter_mut().enumerate() {
*file = repository.load_file(2, index).unwrap(); *file = repository.load_file(2, index).unwrap();
} }
@@ -2727,12 +2842,16 @@ fn branch_changes_resolves_local_and_private_remote_ancestry_without_active_muta
assert_eq!(branch.files.len(), 1); assert_eq!(branch.files.len(), 1);
assert_eq!(branch.files[0].path, Path::new("remote-only")); assert_eq!(branch.files[0].path, Path::new("remote-only"));
assert!(branch.before.ends_with(&base)); assert!(branch.before.ends_with(&base));
assert_eq!(branch.blame[0].as_ref().unwrap().commit, base);
assert!(!branch.blame[1].as_ref().unwrap().store.starts_with(&active));
assert_eq!(repository.load_file(40, 0).unwrap().after, ["remote"]); assert_eq!(repository.load_file(40, 0).unwrap().after, ["remote"]);
let reverse = repository let reverse = repository
.compare(41, remote, &local, ComparisonMode::BranchChanges, &[]) .compare(41, remote, &local, ComparisonMode::BranchChanges, &[])
.unwrap(); .unwrap();
assert_eq!(reverse.files.len(), 1); assert_eq!(reverse.files.len(), 1);
assert_eq!(reverse.files[0].path, Path::new("local-only")); assert_eq!(reverse.files[0].path, Path::new("local-only"));
assert_eq!(reverse.blame[0].as_ref().unwrap().commit, base);
assert_eq!(reverse.blame[1].as_ref().unwrap().commit, local.label);
assert_eq!(repository.load_file(41, 0).unwrap().after, ["local"]); assert_eq!(repository.load_file(41, 0).unwrap().after, ["local"]);
assert!( assert!(
repository repository
@@ -2866,3 +2985,45 @@ fn pull_request_refs_fetch_pinned_fork_heads_privately_for_git_and_jj() {
assert_eq!(disk_state(&root), state); assert_eq!(disk_state(&root), state);
} }
} }
#[test]
fn jj_working_blame_captures_one_parent_and_never_selects_an_arbitrary_merge_parent() {
let fixture = Fixture::new();
let root = fixture.dir("blame-parents");
init_jj(&root, false);
fs::write(root.join("file"), "base\n").unwrap();
jj(&root, &["describe", "-m", "base"]);
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new"]);
let state = disk_state(&root);
let mut repository = open(&root);
let one_parent = repository
.compare(
1,
&working(RepoKind::Jujutsu),
&working(RepoKind::Jujutsu),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert_eq!(one_parent.blame[0].as_ref().unwrap().commit, base);
assert_eq!(one_parent.blame[0], one_parent.blame[1]);
assert_eq!(disk_state(&root), state);
jj(&root, &["new", "root()"]);
fs::write(root.join("other"), "sibling\n").unwrap();
jj(&root, &["describe", "-m", "sibling"]);
let sibling = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new", &base, &sibling]);
let state = disk_state(&root);
let merged = repository
.compare(
2,
&working(RepoKind::Jujutsu),
&working(RepoKind::Jujutsu),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert_eq!(merged.blame, [None, None]);
assert_eq!(disk_state(&root), state);
}
+255 -2
View File
@@ -29,6 +29,26 @@ fn command(root: &Path, program: &str, args: &[&str]) -> String {
); );
String::from_utf8(output.stdout).unwrap().trim().into() String::from_utf8(output.stdout).unwrap().trim().into()
} }
fn disk_state(root: &Path) -> std::collections::BTreeMap<PathBuf, Vec<u8>> {
fn visit(root: &Path, path: &Path, state: &mut std::collections::BTreeMap<PathBuf, Vec<u8>>) {
for entry in fs::read_dir(path).unwrap() {
let path = entry.unwrap().path();
let metadata = fs::symlink_metadata(&path).unwrap();
if metadata.is_dir() {
visit(root, &path, state);
} else if metadata.is_file() {
state.insert(
path.strip_prefix(root).unwrap().to_path_buf(),
fs::read(path).unwrap(),
);
}
}
}
let mut state = std::collections::BTreeMap::new();
visit(root, root, &mut state);
state
}
fn fixture() -> (Temporary, PathBuf, String) { fn fixture() -> (Temporary, PathBuf, String) {
let temp = Temporary::new().unwrap(); let temp = Temporary::new().unwrap();
command(&temp.0, "git", &["init", "--quiet", "--template="]); command(&temp.0, "git", &["init", "--quiet", "--template="]);
@@ -150,6 +170,239 @@ fn missing_untracked_unborn_nonrepo_and_symlink_have_no_blame() {
None None
); );
} }
#[test]
fn historical_git_and_jj_blame_survives_renamed_and_deleted_paths() {
for is_jj in [false, true] {
let (temp, old_path, original) = fixture();
if is_jj {
command(&temp.0, "jj", &["git", "init", "--colocate", "."]);
command(&temp.0, "jj", &["new", &original]);
}
fs::write(&old_path, "historical\nsecond\n").unwrap();
let selected = if is_jj {
command(
&temp.0,
"jj",
&["describe", "-m", "Selected historical lines"],
);
let id = command(
&temp.0,
"jj",
&["log", "--no-graph", "-r", "@", "-T", "commit_id"],
);
command(&temp.0, "jj", &["new"]);
id
} else {
command(&temp.0, "git", &["add", "."]);
command(
&temp.0,
"git",
&["commit", "--quiet", "-m", "Selected historical lines"],
);
command(&temp.0, "git", &["rev-parse", "HEAD"])
};
let renamed = temp.0.join("renamed.txt");
fs::rename(&old_path, &renamed).unwrap();
let renamed_commit = if is_jj {
command(&temp.0, "jj", &["describe", "-m", "Rename"]);
let id = command(
&temp.0,
"jj",
&["log", "--no-graph", "-r", "@", "-T", "commit_id"],
);
command(&temp.0, "jj", &["new"]);
id
} else {
command(&temp.0, "git", &["add", "-A"]);
command(&temp.0, "git", &["commit", "--quiet", "-m", "Rename"]);
command(&temp.0, "git", &["rev-parse", "HEAD"])
};
fs::remove_file(&renamed).unwrap();
if is_jj {
command(&temp.0, "jj", &["describe", "-m", "Delete"]);
command(&temp.0, "jj", &["new"]);
} else {
command(&temp.0, "git", &["add", "-A"]);
command(&temp.0, "git", &["commit", "--quiet", "-m", "Delete"]);
}
let store = if is_jj {
crate::repository::store_for_jj(&temp.0).unwrap()
} else {
temp.0.join(".git")
};
let state = disk_state(&temp.0);
for (commit, path) in [
(
selected.clone(),
old_path.strip_prefix(&temp.0).unwrap().to_path_buf(),
),
(renamed_commit, PathBuf::from("renamed.txt")),
] {
let source = RevisionSource {
store: store.clone(),
commit,
path,
keepalive: None,
};
let mut worker = BlameWorker::start().unwrap();
worker
.request(
1,
old_path.clone(),
"historical\nsecond\n".into(),
Some(source),
)
.unwrap();
let started = Instant::now();
let rows = loop {
if let Some((id, result)) = worker.poll() {
assert_eq!(id, 1);
break result.unwrap().unwrap();
}
assert!(
started.elapsed() < Duration::from_secs(20),
"Historical blame stalled"
);
thread::yield_now();
};
assert_eq!(rows[0].as_ref().unwrap().commit, selected);
assert_eq!(
rows[0].as_ref().unwrap().summary,
"Selected historical lines"
);
assert!(!rows[0].as_ref().unwrap().uncommitted);
assert_eq!(rows[1].as_ref().unwrap().commit, original);
}
assert!(!old_path.exists() && !renamed.exists());
assert_eq!(disk_state(&temp.0), state);
}
}
#[test]
fn privately_fetched_git_and_noncolocated_jj_sources_keep_selected_attribution() {
use crate::diff::{Category, ComparisonMode, Revision, RevisionTarget};
let (remote, remote_path, original) = fixture();
fs::write(&remote_path, "private historical\nsecond\n").unwrap();
command(&remote.0, "git", &["add", "."]);
command(
&remote.0,
"git",
&["commit", "--quiet", "-m", "Private selected"],
);
let selected = command(&remote.0, "git", &["rev-parse", "HEAD"]);
let reference = command(&remote.0, "git", &["symbolic-ref", "HEAD"]);
for is_jj in [false, true] {
let (active, active_path, _) = fixture();
fs::remove_file(&active_path).unwrap();
if is_jj {
fs::remove_dir_all(active.0.join(".git")).unwrap();
command(&active.0, "jj", &["git", "init", "--no-colocate", "."]);
command(
&active.0,
"jj",
&["git", "remote", "add", "origin", remote.0.to_str().unwrap()],
);
} else {
command(
&active.0,
"git",
&["remote", "add", "origin", remote.0.to_str().unwrap()],
);
}
let revision = |id: &str| Revision {
target: RevisionTarget::RemoteCommit {
remote: "origin".into(),
reference: reference.clone(),
id: id.into(),
},
label: id.into(),
description: String::new(),
category: Category::Commits,
};
let state = disk_state(&active.0);
let mut repository =
crate::repository::Repository::open(&active.0, Arc::new(AtomicBool::new(false)))
.unwrap();
let comparison = repository
.compare(
1,
&revision(&original),
&revision(&selected),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
let baseline = comparison.blame[1].as_ref().unwrap();
assert_eq!(baseline.commit, selected);
assert!(!baseline.store.starts_with(&active.0));
let source = RevisionSource {
store: baseline.store.clone(),
commit: baseline.commit.clone(),
path: remote_path.strip_prefix(&remote.0).unwrap().to_path_buf(),
keepalive: baseline.keepalive.clone(),
};
let store = source.store.clone();
drop(comparison);
drop(repository);
assert!(
store.exists(),
"Snapshot provenance must retain private fetched objects"
);
let rows = read_revision(&source, "private historical\nsecond\n", &|| false)
.unwrap()
.unwrap();
assert_eq!(rows[0].as_ref().unwrap().commit, selected);
assert_eq!(rows[1].as_ref().unwrap().commit, original);
assert!(!rows[0].as_ref().unwrap().uncommitted);
assert_eq!(disk_state(&active.0), state);
drop(source);
assert!(
!store.exists(),
"Last snapshot release must remove private objects"
);
}
}
#[test]
fn revision_sources_reject_unsafe_paths_and_skip_missing_or_nonregular_baselines() {
let (temp, path, commit) = fixture();
let mut source = RevisionSource {
store: temp.0.join(".git"),
commit,
path: "not-in-baseline".into(),
keepalive: None,
};
assert_eq!(read_revision(&source, "new\n", &|| false).unwrap(), None);
for path in ["", "../file", "/outside", ".git/config", ".jj/repo"] {
source.path = path.into();
assert!(read_revision(&source, "new\n", &|| false).is_err());
}
std::os::unix::fs::symlink(&path, temp.0.join("link")).unwrap();
fs::create_dir(temp.0.join("nested")).unwrap();
fs::write(temp.0.join("nested/file"), "nested\n").unwrap();
command(&temp.0, "git", &["add", "link", "nested/file"]);
command(&temp.0, "git", &["commit", "--quiet", "-m", "Link"]);
source.commit = command(&temp.0, "git", &["rev-parse", "HEAD"]);
source.path = "link".into();
assert_eq!(read_revision(&source, "target\n", &|| false).unwrap(), None);
source.path = "nested".into();
assert_eq!(read_revision(&source, "nested\n", &|| false).unwrap(), None);
source.path = "nested/file".into();
let nested = read_revision(&source, "nested\n", &|| false)
.unwrap()
.unwrap();
assert_eq!(nested[0].as_ref().unwrap().commit, source.commit);
source.path = path.strip_prefix(&temp.0).unwrap().into();
assert_eq!(
read_revision(&source, "first\n", &|| true).unwrap_err(),
"Blame cancelled"
);
assert_eq!(
read_revision(&source, &"x".repeat(LIMIT + 1), &|| false).unwrap_err(),
"Blame input exceeds 16 MiB"
);
}
#[test] #[test]
fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() { fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() {
let temp = Temporary::new().unwrap(); let temp = Temporary::new().unwrap();
@@ -332,7 +585,7 @@ fn worker_keeps_only_latest_request_and_bounds_input() {
let mut worker = BlameWorker::start().unwrap(); let mut worker = BlameWorker::start().unwrap();
for id in 0..20 { for id in 0..20 {
worker worker
.request(id, path.clone(), "first\nsecond\n".into()) .request(id, path.clone(), "first\nsecond\n".into(), None)
.unwrap(); .unwrap();
} }
loop { loop {
@@ -351,7 +604,7 @@ fn worker_keeps_only_latest_request_and_bounds_input() {
} }
assert_eq!( assert_eq!(
worker worker
.request(20, path, "x".repeat(LIMIT + 1)) .request(20, path, "x".repeat(LIMIT + 1), None)
.unwrap_err() .unwrap_err()
.kind(), .kind(),
io::ErrorKind::InvalidInput io::ErrorKind::InvalidInput
+8 -3
View File
@@ -77,6 +77,9 @@ fn omitted_options_inherit_and_explicit_groups_replace_defaults() {
#[test] #[test]
fn both_positions_accept_all_builtins_and_custom_styles() { fn both_positions_accept_all_builtins_and_custom_styles() {
let defaults = ChromeLine::top();
assert_eq!(defaults.right, ["diff", "shell", "battery"]);
assert_eq!(defaults.segment("diff"), defaults.segment("shell"));
let names = [ let names = [
"tabs", "tabs",
"mode", "mode",
@@ -90,6 +93,7 @@ fn both_positions_accept_all_builtins_and_custom_styles() {
"lsp", "lsp",
"position", "position",
"battery", "battery",
"diff",
"workspace", "workspace",
]; ];
let options = serde_json::json!({ let options = serde_json::json!({
@@ -119,7 +123,7 @@ fn both_positions_accept_all_builtins_and_custom_styles() {
} }
#[test] #[test]
fn view_placeholder_is_valid_in_every_segment_and_bar() { fn comparison_placeholders_are_valid_in_every_segment_and_bar() {
for name in [ for name in [
"tabs", "tabs",
"mode", "mode",
@@ -133,11 +137,12 @@ fn view_placeholder_is_valid_in_every_segment_and_bar() {
"lsp", "lsp",
"position", "position",
"battery", "battery",
"diff",
"workspace", "workspace",
] { ] {
let options = serde_json::json!({ let options = serde_json::json!({
"left": [name], "left": [name],
(name): {"title": "{index}:{view}{name}:{path}{modified}"} (name): {"title": "{index}:{view}{name}{changes}:{path}{modified}"}
}); });
let lines: Lines = serde_json::from_value(serde_json::json!({ let lines: Lines = serde_json::from_value(serde_json::json!({
"top": options, "top": options,
@@ -147,7 +152,7 @@ fn view_placeholder_is_valid_in_every_segment_and_bar() {
for line in [&lines.top, &lines.bottom] { for line in [&lines.top, &lines.bottom] {
assert_eq!( assert_eq!(
line.segment(name).title, line.segment(name).title,
"{index}:{view}{name}:{path}{modified}" "{index}:{view}{name}{changes}:{path}{modified}"
); );
} }
} }
+130 -11
View File
@@ -780,6 +780,7 @@ fn choose_pair(editor: &mut Editor) -> u64 {
fn comparison() -> Comparison { fn comparison() -> Comparison {
Comparison { Comparison {
blame: Default::default(),
revisions: ["abcdef123".into(), "working:snapshot".into()], revisions: ["abcdef123".into(), "working:snapshot".into()],
before: "abcdef123".into(), before: "abcdef123".into(),
after: "Working checkout".into(), after: "Working checkout".into(),
@@ -787,6 +788,8 @@ fn comparison() -> Comparison {
editable_paths: BTreeSet::new(), editable_paths: BTreeSet::new(),
files: vec![ files: vec![
ChangedFile { ChangedFile {
review_revisions: None,
stats: None,
path: "renamed.txt".into(), path: "renamed.txt".into(),
old_path: Some("old.txt".into()), old_path: Some("old.txt".into()),
status: FileStatus::Renamed, status: FileStatus::Renamed,
@@ -824,6 +827,8 @@ fn comparison() -> Comparison {
notice: None, notice: None,
}, },
ChangedFile { ChangedFile {
review_revisions: None,
stats: None,
path: "added.txt".into(), path: "added.txt".into(),
old_path: None, old_path: None,
status: FileStatus::Added, status: FileStatus::Added,
@@ -841,6 +846,8 @@ fn comparison() -> Comparison {
notice: None, notice: None,
}, },
ChangedFile { ChangedFile {
review_revisions: None,
stats: None,
path: "removed.bin".into(), path: "removed.bin".into(),
old_path: None, old_path: None,
status: FileStatus::Removed, status: FileStatus::Removed,
@@ -856,6 +863,19 @@ fn comparison() -> Comparison {
} }
} }
#[test]
fn line_counts_use_frozen_metadata_only_until_hunks_are_loaded() {
let mut file = comparison().files.remove(0);
file.loaded = false;
assert_eq!(file.line_counts(), None);
file.stats = Some((99, 98));
assert_eq!(file.line_counts(), Some((99, 98)));
file.loaded = true;
assert_eq!(file.line_counts(), Some((2, 2)));
file.hunks.clear();
assert_eq!(file.line_counts(), Some((0, 0)));
}
#[test] #[test]
fn status_layout_cycles_only_present_panes_and_normalizes_selection() { fn status_layout_cycles_only_present_panes_and_normalizes_selection() {
let mut state = live_state(vec!["old".into()], vec!["new".into()], true); let mut state = live_state(vec!["old".into()], vec!["new".into()], true);
@@ -1561,6 +1581,8 @@ fn closing_diff_rejects_pending_file_details_even_after_reopening() {
let detail = catalog.files.remove(0); let detail = catalog.files.remove(0);
catalog.files.clear(); catalog.files.clear();
catalog.files.push(ChangedFile { catalog.files.push(ChangedFile {
review_revisions: None,
stats: None,
path: detail.path.clone(), path: detail.path.clone(),
old_path: detail.old_path.clone(), old_path: detail.old_path.clone(),
status: detail.status, status: detail.status,
@@ -1770,6 +1792,7 @@ fn jujutsu_categories_empty_comparison_and_command_mode_entry_are_complete() {
assert!(editor.apply_diff_result( assert!(editor.apply_diff_result(
id, id,
Ok(Outcome::Comparison(Comparison { Ok(Outcome::Comparison(Comparison {
blame: Default::default(),
revisions: ["working:snapshot".into(), "working:snapshot".into()], revisions: ["working:snapshot".into(), "working:snapshot".into()],
before: "Working".into(), before: "Working".into(),
after: "Working".into(), after: "Working".into(),
@@ -1802,12 +1825,15 @@ fn live_state(before: Vec<String>, after: Vec<String>, eof: bool) -> DiffState {
Category::Commits, Category::Commits,
)); ));
state.comparison = Some(Comparison { state.comparison = Some(Comparison {
blame: Default::default(),
revisions: ["base".into(), "working:snapshot".into()], revisions: ["base".into(), "working:snapshot".into()],
root: PathBuf::new(), root: PathBuf::new(),
before: "base".into(), before: "base".into(),
after: "Working".into(), after: "Working".into(),
editable_paths: BTreeSet::from([PathBuf::from("file")]), editable_paths: BTreeSet::from([PathBuf::from("file")]),
files: vec![ChangedFile { files: vec![ChangedFile {
review_revisions: None,
stats: None,
path: "file".into(), path: "file".into(),
old_path: None, old_path: None,
status: FileStatus::Modified, status: FileStatus::Modified,
@@ -2164,10 +2190,12 @@ fn reviewed_files_survive_lazy_details_and_views_but_reset_on_comparison() {
} }
#[test] #[test]
fn restored_review_marks_use_revision_pairs_and_rename_paths_not_catalog_order() { fn restored_review_marks_use_file_identities_and_rename_paths_not_catalog_order() {
let mut state = DiffState::new(); let mut state = DiffState::new();
state.request_kind = Some(RequestKind::Comparison); state.request_kind = Some(RequestKind::Comparison);
state.apply_result(1, Ok(Outcome::Comparison(comparison()))); state.apply_result(1, Ok(Outcome::Comparison(comparison())));
state.comparison.as_mut().unwrap().files[0].review_revisions =
Some(["file:before".into(), "file:after".into()]);
assert_eq!(state.toggle_reviewed(), Ok(true)); assert_eq!(state.toggle_reviewed(), Ok(true));
state.comparison.as_mut().unwrap().files.swap(0, 1); state.comparison.as_mut().unwrap().files.swap(0, 1);
state.restore_reviewed_files(); state.restore_reviewed_files();
@@ -2177,17 +2205,104 @@ fn restored_review_marks_use_revision_pairs_and_rename_paths_not_catalog_order()
assert!(state.reviewed_files.is_empty()); assert!(state.reviewed_files.is_empty());
state.comparison.as_mut().unwrap().files[1].old_path = Some("old.txt".into()); state.comparison.as_mut().unwrap().files[1].old_path = Some("old.txt".into());
for side in [0, 1] { for side in [0, 1] {
let original = state.comparison.as_ref().unwrap().revisions[side].clone(); let identities = state.comparison.as_mut().unwrap().files[1]
state.comparison.as_mut().unwrap().revisions[side].push_str("-different"); .review_revisions
.as_mut()
.unwrap();
let original = identities[side].clone();
identities[side].push_str("-different");
state.restore_reviewed_files(); state.restore_reviewed_files();
assert!(state.reviewed_files.is_empty()); assert!(state.reviewed_files.is_empty());
state.comparison.as_mut().unwrap().revisions[side] = original; state.comparison.as_mut().unwrap().files[1]
.review_revisions
.as_mut()
.unwrap()[side] = original;
} }
state.comparison.as_mut().unwrap().files[1].loaded = false; state.comparison.as_mut().unwrap().files[1].loaded = false;
state.restore_reviewed_files(); state.restore_reviewed_files();
assert_eq!(state.reviewed_files, BTreeSet::from([1])); assert_eq!(state.reviewed_files, BTreeSet::from([1]));
} }
#[test]
fn unchanged_file_review_survives_other_files_changing() {
use crate::repository::Repository;
use std::sync::{Arc, atomic::AtomicBool};
let root = std::env::temp_dir().join(format!("vamp-review-files-{}", std::process::id()));
std::fs::create_dir(&root).unwrap();
let git = |args: &[&str]| {
let output = std::process::Command::new("git")
.current_dir(&root)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_owned()
};
git(&["init", "--initial-branch=local"]);
git(&["config", "user.name", "Review Test"]);
git(&["config", "user.email", "review@example.invalid"]);
for path in ["a.txt", "b.txt"] {
std::fs::write(root.join(path), "base\n").unwrap();
}
git(&["add", "."]);
git(&["commit", "-m", "base"]);
for path in ["a.txt", "b.txt"] {
std::fs::write(root.join(path), "changed\n").unwrap();
}
let before = revision(
RevisionTarget::Commit(git(&["rev-parse", "HEAD"])),
"base",
"",
Category::Commits,
);
let after = revision(RevisionTarget::Working, "Working", "", Category::Commits);
let mut repository = Repository::open(&root, Arc::new(AtomicBool::new(false))).unwrap();
let mut state = DiffState::new();
let changes = repository
.compare(1, &before, &after, ComparisonMode::Snapshots, &[])
.unwrap();
let original_revisions = changes.revisions.clone();
state.request_kind = Some(RequestKind::Comparison);
state.apply_result(1, Ok(Outcome::Comparison(changes)));
state.toggle_reviewed().unwrap();
state.file = 1;
state.focus = Focus::Before;
state.toggle_reviewed().unwrap();
let mut store = crate::review::Store::open(&root.join("reviews")).unwrap();
store.save(&mut state.local_review.document).unwrap();
let id = state.local_review.document.storage_id.unwrap();
std::fs::write(root.join("b.txt"), "edited again\n").unwrap();
let changes = repository
.compare(2, &before, &after, ComparisonMode::Snapshots, &[])
.unwrap();
assert_ne!(changes.revisions, original_revisions);
state.request_kind = Some(RequestKind::Comparison);
state.apply_result(2, Ok(Outcome::Comparison(changes)));
state.local_review.document = store.load(&root, id).unwrap();
state.restore_reviewed_files();
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
let detail = repository.load_file(2, 0).unwrap();
state.apply_result(
3,
Ok(Outcome::File {
comparison_id: 2,
file_index: 0,
file: detail,
}),
);
state.restore_reviewed_files();
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
std::fs::remove_dir_all(root).unwrap();
}
#[test] #[test]
fn restored_live_review_marks_require_exact_text_and_final_newline() { fn restored_live_review_marks_require_exact_text_and_final_newline() {
let mut state = live_state(vec!["base".into()], vec!["changed".into()], false); let mut state = live_state(vec!["base".into()], vec!["changed".into()], false);
@@ -2405,6 +2520,8 @@ fn reverting_loaded_file_keeps_unloaded_changes_selectable() {
state.comparison_id = Some(17); state.comparison_id = Some(17);
state.pending = None; state.pending = None;
state.comparison.as_mut().unwrap().files.push(ChangedFile { state.comparison.as_mut().unwrap().files.push(ChangedFile {
review_revisions: None,
stats: None,
path: "unloaded.txt".into(), path: "unloaded.txt".into(),
old_path: None, old_path: None,
status: FileStatus::Modified, status: FileStatus::Modified,
@@ -2632,6 +2749,8 @@ fn lazy_selection_waits_for_details_rejects_stale_files_and_reuses_loaded_cache(
let details: Vec<_> = catalog.files.drain(..).collect(); let details: Vec<_> = catalog.files.drain(..).collect();
for detail in &details { for detail in &details {
catalog.files.push(ChangedFile { catalog.files.push(ChangedFile {
review_revisions: None,
stats: None,
path: detail.path.clone(), path: detail.path.clone(),
old_path: detail.old_path.clone(), old_path: detail.old_path.clone(),
status: detail.status, status: detail.status,
@@ -2776,15 +2895,15 @@ fn review_anchors_match_frozen_context_and_exact_live_eof_without_allocating() {
end: crate::plugin::TextPosition { row: 1, column: 0 }, 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_range(&anchor).is_some());
state.live_files.insert(0); state.live_files.insert(0);
assert!(!state.review_anchor_current(&anchor)); assert!(state.review_anchor_range(&anchor).is_none());
state.comparison.as_mut().unwrap().files[0] state.comparison.as_mut().unwrap().files[0]
.after .after
.push(String::new()); .push(String::new());
assert!(state.review_anchor_current(&anchor)); assert!(state.review_anchor_range(&anchor).is_some());
state.comparison.as_mut().unwrap().files[0].after[0] = "changed".into(); state.comparison.as_mut().unwrap().files[0].after[0] = "changed".into();
assert!(!state.review_anchor_current(&anchor)); assert!(state.review_anchor_range(&anchor).is_none());
state.comparison.as_mut().unwrap().files[0].old_path = Some("original".into()); state.comparison.as_mut().unwrap().files[0].old_path = Some("original".into());
let before = Anchor { let before = Anchor {
revision: "base".into(), revision: "base".into(),
@@ -2793,12 +2912,12 @@ fn review_anchors_match_frozen_context_and_exact_live_eof_without_allocating() {
content: "old\n".into(), content: "old\n".into(),
..anchor ..anchor
}; };
assert!(state.review_anchor_current(&before)); assert!(state.review_anchor_range(&before).is_some());
state.comparison.as_mut().unwrap().files[0].loaded = false; state.comparison.as_mut().unwrap().files[0].loaded = false;
assert!(!state.review_anchor_current(&before)); assert!(state.review_anchor_range(&before).is_none());
state.comparison.as_mut().unwrap().files[0].loaded = true; state.comparison.as_mut().unwrap().files[0].loaded = true;
state.comparison.as_mut().unwrap().revisions[0] = "other".into(); state.comparison.as_mut().unwrap().revisions[0] = "other".into();
assert!(!state.review_anchor_current(&before)); assert!(state.review_anchor_range(&before).is_none());
} }
#[test] #[test]
+205 -4
View File
@@ -1879,12 +1879,15 @@ fn hidden_tab_diff_results_restore_visible_insert_interaction_and_route_request_
assert!(editor.apply_diff_result( assert!(editor.apply_diff_result(
comparison_request, comparison_request,
Ok(Outcome::Comparison(Comparison { Ok(Outcome::Comparison(Comparison {
blame: Default::default(),
root: editor.project_root().to_owned(), root: editor.project_root().to_owned(),
revisions: ["base".into(), "working-snapshot".into()], revisions: ["base".into(), "working-snapshot".into()],
before: "base".into(), before: "base".into(),
after: "working".into(), after: "working".into(),
editable_paths: Default::default(), editable_paths: Default::default(),
files: vec![ChangedFile { files: vec![ChangedFile {
review_revisions: None,
stats: None,
loaded: true, loaded: true,
path: "file.txt".into(), path: "file.txt".into(),
old_path: None, old_path: None,
@@ -3050,6 +3053,8 @@ fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathB
let text = fs::read_to_string(root.join(path)).unwrap(); let text = fs::read_to_string(root.join(path)).unwrap();
let lines: Vec<String> = text.lines().map(str::to_owned).collect(); let lines: Vec<String> = text.lines().map(str::to_owned).collect();
ChangedFile { ChangedFile {
review_revisions: None,
stats: None,
loaded: true, loaded: true,
path: PathBuf::from(*path), path: PathBuf::from(*path),
old_path: None, old_path: None,
@@ -3066,6 +3071,7 @@ fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathB
assert!(editor.apply_diff_result( assert!(editor.apply_diff_result(
id, id,
Ok(Outcome::Comparison(Comparison { Ok(Outcome::Comparison(Comparison {
blame: Default::default(),
root: root.canonicalize().unwrap(), root: root.canonicalize().unwrap(),
revisions: ["base".into(), "working-snapshot".into()], revisions: ["base".into(), "working-snapshot".into()],
before: "base".into(), before: "base".into(),
@@ -3103,12 +3109,15 @@ fn historical_diff(editor: &mut Editor, root: &Path, before: Vec<String>, after:
editor.apply_diff_result( editor.apply_diff_result(
id, id,
Ok(Outcome::Comparison(Comparison { Ok(Outcome::Comparison(Comparison {
blame: Default::default(),
root: root.to_owned(), root: root.to_owned(),
revisions: ["base".into(), "head".into()], revisions: ["base".into(), "head".into()],
before: "base".into(), before: "base".into(),
after: "head".into(), after: "head".into(),
editable_paths: Default::default(), editable_paths: Default::default(),
files: vec![ChangedFile { files: vec![ChangedFile {
review_revisions: None,
stats: None,
loaded: true, loaded: true,
path: "file.txt".into(), path: "file.txt".into(),
old_path: None, old_path: None,
@@ -3273,6 +3282,85 @@ fn editing_a_reviewed_working_file_persists_its_invalidation() {
assert!(store.load(&root, id).unwrap().reviewed_files.is_empty()); assert!(store.load(&root, id).unwrap().reviewed_files.is_empty());
} }
#[test]
fn review_navigation_maps_working_ranges_and_preserves_stale_snapshots() {
use crate::review::{Anchor, Comment, Side};
let directory = Directory::new();
let root = directory.0.canonicalize().unwrap();
fs::write(root.join("file.txt"), "header\nunique target\ntail\n").unwrap();
let mut editor = editor();
working_diff(&mut editor, &root, &["file.txt"]);
editor.handle(Input::Enter);
editor
.diff
.as_mut()
.unwrap()
.comparison
.as_mut()
.unwrap()
.revisions[1] = "working:old".into();
editor.diff.as_mut().unwrap().local_review.document = crate::review::Review::new(root);
let anchor = Anchor {
revision: "working:old".into(),
path: "file.txt".into(),
side: Side::After,
start: TextPosition { row: 1, column: 7 },
end: TextPosition { row: 1, column: 13 },
content: editor.lines().join("\n"),
};
editor
.diff
.as_mut()
.unwrap()
.local_review
.document
.comments
.push(Comment {
id: 1,
anchor: anchor.clone(),
body: "Feedback".into(),
resolved: false,
});
editor
.apply(vec![Effect::Replace(
"inserted\nheader\nprefix unique target\ntail\n".into(),
)])
.unwrap();
editor.refresh_live_diff();
editor
.diff
.as_mut()
.unwrap()
.comparison
.as_mut()
.unwrap()
.revisions[1] = "working:new".into();
editor.execute("diff.review.edit 1");
let owner = editor.diff_owner.unwrap();
editor.select_pane(owner);
assert_eq!(editor.cursor(), Cursor { row: 2, column: 14 });
assert_eq!(
editor.diff.as_ref().unwrap().local_review.document.comments[0].anchor,
anchor
);
editor
.apply(vec![Effect::Replace(
"inserted\nheader\nprefix unique changed\ntail\n".into(),
)])
.unwrap();
editor.refresh_live_diff();
set_carets(&mut editor, &[(0, 0)], 0);
editor.execute("diff.review.edit 1");
assert!(editor.message().contains("stale"));
editor.select_pane(owner);
assert_eq!(editor.cursor(), Cursor::default());
assert_eq!(
editor.diff.as_ref().unwrap().local_review.document.comments[0].anchor,
anchor
);
}
#[test] #[test]
fn sqlite_reviews_autosave_restore_select_and_keep_exports_independent() { fn sqlite_reviews_autosave_restore_select_and_keep_exports_independent() {
use crate::review::{Review, Store, Verdict}; use crate::review::{Review, Store, Verdict};
@@ -3353,10 +3441,11 @@ fn sqlite_reviews_autosave_restore_select_and_keep_exports_independent() {
assert_eq!(restored.storage_id, Some(id)); assert_eq!(restored.storage_id, Some(id));
assert_eq!(restored.comments[0].body, "First feedback revised"); assert_eq!(restored.comments[0].body, "First feedback revised");
assert!( assert!(
!reopened reopened
.workspace_diff() .workspace_diff()
.unwrap() .unwrap()
.review_anchor_current(&restored.comments[0].anchor) .review_anchor_range(&restored.comments[0].anchor)
.is_none()
); );
reopened.execute("diff.review.new"); reopened.execute("diff.review.new");
let second_id = reopened let second_id = reopened
@@ -3768,7 +3857,7 @@ fn precise_review_ranges_capture_graphemes_and_navigate_to_their_start() {
}; };
assert_eq!(anchor.start, start); assert_eq!(anchor.start, start);
assert_eq!(anchor.end, end); assert_eq!(anchor.end, end);
assert!(diff.review_anchor_current(anchor)); assert!(diff.review_anchor_range(anchor).is_some());
editor.handle(Input::Escape); editor.handle(Input::Escape);
assert_eq!(editor.pane_id(), owner); assert_eq!(editor.pane_id(), owner);
assert_eq!( assert_eq!(
@@ -3897,7 +3986,7 @@ fn local_review_visual_commands_and_palette_preserve_both_side_ranges() {
Side::After Side::After
} }
); );
assert!(diff.review_anchor_current(&anchor)); assert!(diff.review_anchor_range(&anchor).is_some());
let source_pane = editor.diff_owner.unwrap(); let source_pane = editor.diff_owner.unwrap();
editor.select_pane(source_pane); editor.select_pane(source_pane);
editor editor
@@ -4071,6 +4160,8 @@ fn bookmark_refresh_reloads_paths_and_preserves_view_until_lazy_content_arrives(
let old_snapshot = editor.buffer_id(); let old_snapshot = editor.buffer_id();
let cursor = editor.cursor(); let cursor = editor.cursor();
let file = |path: &str, loaded| ChangedFile { let file = |path: &str, loaded| ChangedFile {
review_revisions: None,
stats: None,
path: path.into(), path: path.into(),
old_path: None, old_path: None,
status: FileStatus::Modified, status: FileStatus::Modified,
@@ -4104,6 +4195,7 @@ fn bookmark_refresh_reloads_paths_and_preserves_view_until_lazy_content_arrives(
assert!(editor.apply_diff_result( assert!(editor.apply_diff_result(
refresh, refresh,
Ok(Outcome::BookmarkRefresh(Comparison { Ok(Outcome::BookmarkRefresh(Comparison {
blame: Default::default(),
root: directory.0.clone(), root: directory.0.clone(),
revisions: ["base".into(), "topic-snapshot".into()], revisions: ["base".into(), "topic-snapshot".into()],
before: "base".into(), before: "base".into(),
@@ -4225,6 +4317,7 @@ fn completed_working_refresh_serializes_lazy_selection_and_invalidates_pending_n
let loaded_after = target.after.clone(); let loaded_after = target.after.clone();
target.loaded = false; target.loaded = false;
let replacement = crate::diff::Comparison { let replacement = crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(), root: directory.0.clone(),
revisions: ["base".into(), "ahead-snapshot".into()], revisions: ["base".into(), "ahead-snapshot".into()],
before: "base".into(), before: "base".into(),
@@ -4278,6 +4371,8 @@ fn completed_working_refresh_serializes_lazy_selection_and_invalidates_pending_n
} }
if changed { if changed {
let file = crate::diff::ChangedFile { let file = crate::diff::ChangedFile {
review_revisions: None,
stats: None,
path: "nested/new.txt".into(), path: "nested/new.txt".into(),
old_path: Some("nested/old.txt".into()), old_path: Some("nested/old.txt".into()),
status: crate::diff::FileStatus::Renamed, status: crate::diff::FileStatus::Renamed,
@@ -4318,6 +4413,8 @@ fn lazy_lsp_target(editor: &mut Editor) -> crate::diff::ChangedFile {
let diff = editor.diff.as_mut().unwrap(); let diff = editor.diff.as_mut().unwrap();
let comparison = diff.comparison.as_mut().unwrap(); let comparison = diff.comparison.as_mut().unwrap();
comparison.files.push(ChangedFile { comparison.files.push(ChangedFile {
review_revisions: None,
stats: None,
path: "nested/new.txt".into(), path: "nested/new.txt".into(),
old_path: Some("nested/old.txt".into()), old_path: Some("nested/old.txt".into()),
status: FileStatus::Renamed, status: FileStatus::Renamed,
@@ -4338,6 +4435,8 @@ fn lazy_lsp_target(editor: &mut Editor) -> crate::diff::ChangedFile {
.map(|(index, file)| (index, file.path.clone())), .map(|(index, file)| (index, file.path.clone())),
)); ));
ChangedFile { ChangedFile {
review_revisions: None,
stats: None,
path: "nested/new.txt".into(), path: "nested/new.txt".into(),
old_path: Some("nested/old.txt".into()), old_path: Some("nested/old.txt".into()),
status: FileStatus::Renamed, status: FileStatus::Renamed,
@@ -7450,6 +7549,108 @@ mod system_clipboard {
} }
} }
#[test]
fn review_copy_uses_cli_text_and_configured_clipboard_without_saving_drafts() {
use crate::review::{Anchor, Comment, Review, Side, Store, Verdict};
for mode in ["system", "separate"] {
let fixture = Fixture::new();
let root = fixture.0.0.canonicalize().unwrap();
let storage = root.join("reviews");
let mut review = Review::new(root.clone());
review.summary = "Review summary".into();
review.verdict = Some(Verdict::ChangesRequested);
review.comments = vec![
Comment {
id: 1,
body: "First\nfeedback".into(),
resolved: false,
anchor: Anchor {
revision: "base".into(),
path: "file.txt".into(),
side: Side::Before,
start: TextPosition { row: 0, column: 1 },
end: TextPosition { row: 1, column: 1 },
content: "xe\u{301}👩‍💻\n界z".into(),
},
},
Comment {
id: 2,
body: "Resolved feedback".into(),
resolved: true,
anchor: Anchor {
revision: "stale".into(),
path: "other.rs".into(),
side: Side::After,
start: TextPosition { row: 0, column: 0 },
end: TextPosition { row: 0, column: 3 },
content: "old".into(),
},
},
];
let mut store = Store::open(&storage).unwrap();
store.save(&mut review).unwrap();
let id = review.storage_id.unwrap();
let expected = review.format_text(true);
let mut editor = fixture.editor();
editor.set_clipboard_mode(mode).unwrap();
editor.clipboard = vec!["internal".into()];
fixture.external("external");
editor.execute("diff.review.copy");
assert_eq!(editor.clipboard, ["internal"]);
assert_eq!(fixture.bytes("state"), b"external");
editor.set_review_directory(storage);
historical_diff(
&mut editor,
&root,
vec!["xe\u{301}👩‍💻".into(), "界z".into()],
vec!["new".into()],
);
editor.execute("diff.review.edit 1");
type_text(&mut editor, "A UNSAVED");
editor.handle(Input::Escape);
let draft = editor.context().text;
editor.execute("diff.review.copy");
assert_eq!(editor.context().text, draft);
assert!(editor.local_reviews_dirty());
let stored = store.load(&root, id).unwrap();
assert_eq!(stored.storage_revision, review.storage_revision);
assert_eq!(stored.comments, review.comments);
if mode == "system" {
assert_eq!(fixture.bytes("state"), expected.as_bytes());
assert_eq!(editor.clipboard, ["internal"]);
let owned = editor.owned_system_clipboard.clone();
fixture.fail("fail-write", true);
editor.execute("diff.review.copy");
assert_eq!(fixture.bytes("state"), expected.as_bytes());
assert_eq!(editor.owned_system_clipboard, owned);
} else {
assert_eq!(editor.clipboard, [expected.clone()]);
assert!(!editor.clipboard_linewise);
assert_eq!(fixture.bytes("state"), b"external");
assert!(fixture.bytes("writes").is_empty());
}
editor.execute("diff.review.copy unexpected");
editor.with_pane(editor.diff_owner.unwrap(), |editor| {
editor
.diff
.as_mut()
.unwrap()
.local_review
.document
.comments
.clear();
});
editor.execute("diff.review.copy");
if mode == "system" {
assert_eq!(fixture.bytes("state"), expected.as_bytes());
} else {
assert_eq!(editor.clipboard, [expected]);
}
}
}
#[test] #[test]
fn line_actions_clipboard_failure_preserves_carets_history_and_repeat() { fn line_actions_clipboard_failure_preserves_carets_history_and_repeat() {
let fixture = Fixture::new(); let fixture = Fixture::new();
+111 -8
View File
@@ -67,13 +67,18 @@ fn database_restarts_preserve_identity_and_exact_stale_anchors() {
assert_eq!(loaded.comments, review.comments); assert_eq!(loaded.comments, review.comments);
assert_eq!(loaded.reviewed_files, review.reviewed_files); assert_eq!(loaded.reviewed_files, review.reviewed_files);
assert_eq!(loaded.verdict, review.verdict); assert_eq!(loaded.verdict, review.verdict);
assert!(!loaded.comments[0].anchor.matches_lines( assert!(
loaded.comments[0]
.anchor
.mapped_range(
"working:new-snapshot", "working:new-snapshot",
Path::new("日本語.rs"), Path::new("日本語.rs"),
Side::Before, Side::Before,
&["changed".into()], &["changed".into()],
true, true,
)); )
.is_none()
);
let path = root.join("export.json"); let path = root.join("export.json");
loaded.save(&path).unwrap(); loaded.save(&path).unwrap();
let exported = fs::read(&path).unwrap(); let exported = fs::read(&path).unwrap();
@@ -387,6 +392,92 @@ fn default_directory_uses_absolute_xdg_then_absolute_home() {
assert!(resolve(None, Some("")).is_err()); assert!(resolve(None, Some("")).is_err());
} }
#[test]
fn anchor_survives_unrelated_edit() {
let anchor = Anchor {
revision: "working:old".into(),
path: "source.rs".into(),
side: Side::After,
start: crate::plugin::TextPosition { row: 1, column: 0 },
end: crate::plugin::TextPosition { row: 1, column: 6 },
content: "before\ntarget\nafter\n".into(),
};
let map = |text: &str| {
anchor.mapped_range(
"working:new",
Path::new("source.rs"),
Side::After,
&text
.trim_end_matches('\n')
.split('\n')
.map(str::to_owned)
.collect::<Vec<_>>(),
text.ends_with('\n'),
)
};
assert_eq!(
map("changed\ntarget\nafter\n"),
Some(anchor.start..anchor.end)
);
assert_eq!(
map("inserted\nbefore\ntarget\nafter\n"),
Some(
crate::plugin::TextPosition { row: 2, column: 0 }..crate::plugin::TextPosition {
row: 2,
column: 6
}
)
);
assert!(map("before\ntargEt\nafter\n").is_none());
assert!(map("before\nafter\n").is_none());
assert!(map("before\ntarget\nafter\nbefore\ntarget\nafter\n").is_none());
assert_eq!(map("target\nbefore\ntarget\nafter\n").unwrap().start.row, 2);
assert_eq!(anchor.content, "before\ntarget\nafter\n");
let partial = Anchor {
content: "header\r\nprefix 👩‍💻e\u{301} suffix\r\nfooter\r\n".into(),
start: crate::plugin::TextPosition { row: 1, column: 7 },
end: crate::plugin::TextPosition { row: 1, column: 9 },
..anchor.clone()
};
let moved = partial
.mapped_range(
"working:new",
Path::new("source.rs"),
Side::After,
&[
"inserted\r".into(),
"header\r".into(),
"longer prefix 👩‍💻e\u{301} suffix\r".into(),
"footer\r".into(),
],
true,
)
.unwrap();
assert_eq!(
moved.start,
crate::plugin::TextPosition { row: 2, column: 14 }
);
assert_eq!(
moved.end,
crate::plugin::TextPosition { row: 2, column: 16 }
);
assert!(
partial
.mapped_range(
"other-commit",
Path::new("source.rs"),
Side::After,
&[
"header\r".into(),
"prefix 👩‍💻e\u{301} suffix\r".into(),
"footer\r".into()
],
true
)
.is_none()
);
}
#[test] #[test]
fn persistence_validates_anchors_and_preserves_files_on_failure() { fn persistence_validates_anchors_and_preserves_files_on_failure() {
let directory = directory(); let directory = directory();
@@ -413,27 +504,39 @@ fn persistence_validates_anchors_and_preserves_files_on_failure() {
assert_eq!(loaded.comments, review.comments); assert_eq!(loaded.comments, review.comments);
assert_eq!(loaded.verdict, review.verdict); assert_eq!(loaded.verdict, review.verdict);
let anchor = &loaded.comments[0].anchor; let anchor = &loaded.comments[0].anchor;
assert!(!anchor.matches_lines( assert!(
anchor
.mapped_range(
"working:other", "working:other",
Path::new("source.rs"), Path::new("source.rs"),
Side::After, Side::After,
&["first".into(), "last".into()], &["first".into(), "last".into()],
true true
)); )
assert!(anchor.matches_lines( .is_some()
);
assert!(
anchor
.mapped_range(
"working:immutable", "working:immutable",
Path::new("source.rs"), Path::new("source.rs"),
Side::After, Side::After,
&["first".into(), "last".into()], &["first".into(), "last".into()],
true true
)); )
assert!(!anchor.matches_lines( .is_some()
);
assert!(
anchor
.mapped_range(
"working:immutable", "working:immutable",
Path::new("source.rs"), Path::new("source.rs"),
Side::After, Side::After,
&["first".into(), "last".into()], &["first".into(), "last".into()],
false false
)); )
.is_none()
);
loaded.summary = "Updated".into(); loaded.summary = "Updated".into();
loaded.save(&path).unwrap(); loaded.save(&path).unwrap();
assert_eq!(Review::load(&path, &root).unwrap().summary, "Updated"); assert_eq!(Review::load(&path, &root).unwrap().summary, "Updated");
+1 -1
View File
@@ -1,7 +1,7 @@
use super::*; use super::*;
use vamp::{ use vamp::{
plugin::TextPosition, plugin::TextPosition,
review::{Comment, Side}, review::{Anchor, Comment, Side},
}; };
#[test] #[test]
+4
View File
@@ -588,6 +588,7 @@ fn checkout_diff_polling_uses_injected_interval_independently_of_endpoint_follow
session.editor.apply_diff_result( session.editor.apply_diff_result(
id, id,
Ok(DiffOutcome::Comparison(Comparison { Ok(DiffOutcome::Comparison(Comparison {
blame: Default::default(),
revisions: ["base".into(), "after".into()], revisions: ["base".into(), "after".into()],
root: fixture.0.clone(), root: fixture.0.clone(),
before: "base".into(), before: "base".into(),
@@ -3721,12 +3722,15 @@ fn async_responses_edit_only_current_bound_working_after_and_refresh_projection(
session.editor.apply_diff_result( session.editor.apply_diff_result(
id, id,
Ok(DiffOutcome::Comparison(Comparison { Ok(DiffOutcome::Comparison(Comparison {
blame: Default::default(),
revisions: ["base".into(), "working:snapshot".into()], revisions: ["base".into(), "working:snapshot".into()],
root: fixture.0.canonicalize().unwrap(), root: fixture.0.canonicalize().unwrap(),
before: "base".into(), before: "base".into(),
after: "Working".into(), after: "Working".into(),
editable_paths: [PathBuf::from("buffer.txt")].into_iter().collect(), editable_paths: [PathBuf::from("buffer.txt")].into_iter().collect(),
files: vec![ChangedFile { files: vec![ChangedFile {
review_revisions: None,
stats: None,
path: PathBuf::from("buffer.txt"), path: PathBuf::from("buffer.txt"),
old_path: None, old_path: None,
status: FileStatus::Modified, status: FileStatus::Modified,
+356 -23
View File
@@ -1984,6 +1984,8 @@ fn syntax_diff_file(path: &str, before: &str, after: &str) -> ChangedFile {
let before: Vec<String> = before.split('\n').map(str::to_owned).collect(); let before: Vec<String> = before.split('\n').map(str::to_owned).collect();
let after: Vec<String> = after.split('\n').map(str::to_owned).collect(); let after: Vec<String> = after.split('\n').map(str::to_owned).collect();
ChangedFile { ChangedFile {
review_revisions: None,
stats: None,
loaded: true, loaded: true,
before_eof: false, before_eof: false,
after_eof: false, after_eof: false,
@@ -2592,6 +2594,7 @@ fn diff_comparison(files: Vec<ChangedFile>) -> DiffState {
diff.apply_result( diff.apply_result(
id, id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: std::path::PathBuf::new(), root: std::path::PathBuf::new(),
editable_paths: Default::default(), editable_paths: Default::default(),
before: "old revision".into(), before: "old revision".into(),
@@ -2605,6 +2608,8 @@ fn diff_comparison(files: Vec<ChangedFile>) -> DiffState {
fn deletion_file() -> ChangedFile { fn deletion_file() -> ChangedFile {
ChangedFile { ChangedFile {
review_revisions: None,
stats: None,
loaded: true, loaded: true,
before_eof: false, before_eof: false,
after_eof: false, after_eof: false,
@@ -3220,7 +3225,7 @@ fn local_review_markers_reject_stale_identity_text_and_rename_paths() {
let anchor = &mut diff.local_review.document.comments[0].anchor; let anchor = &mut diff.local_review.document.comments[0].anchor;
match mismatch { match mismatch {
"revision" => anchor.revision.push_str("-newer"), "revision" => anchor.revision.push_str("-newer"),
"content" => anchor.content.push('\n'), "content" => anchor.content.replace_range(..1, "X"),
"path" => anchor.path = "new.txt".into(), "path" => anchor.path = "new.txt".into(),
"side" => anchor.side = crate::review::Side::After, "side" => anchor.side = crate::review::Side::After,
"root" => diff.local_review.document.root = "another-repository".into(), "root" => diff.local_review.document.root = "another-repository".into(),
@@ -3555,6 +3560,8 @@ fn diff_excerpts_render_original_lines_and_expand_without_losing_changes() {
after[30] = "let first_change = 900;".into(); after[30] = "let first_change = 900;".into();
after[60] = "let second_change = 901;".into(); after[60] = "let second_change = 901;".into();
let mut diff = diff_comparison(vec![ChangedFile { let mut diff = diff_comparison(vec![ChangedFile {
review_revisions: None,
stats: None,
loaded: true, loaded: true,
before_eof: false, before_eof: false,
after_eof: false, after_eof: false,
@@ -3694,6 +3701,8 @@ fn diff_row_tint_covers_blank_lines_and_wrapped_gutters() {
#[test] #[test]
fn diff_added_rows_hatch_missing_before_but_tint_real_empty_after_lines() { fn diff_added_rows_hatch_missing_before_but_tint_real_empty_after_lines() {
let mut diff = diff_comparison(vec![ChangedFile { let mut diff = diff_comparison(vec![ChangedFile {
review_revisions: None,
stats: None,
loaded: true, loaded: true,
before_eof: false, before_eof: false,
after_eof: false, after_eof: false,
@@ -3751,6 +3760,8 @@ fn diff_added_rows_hatch_missing_before_but_tint_real_empty_after_lines() {
#[test] #[test]
fn diff_changed_backgrounds_preserve_syntax_and_clipped_source_cells() { fn diff_changed_backgrounds_preserve_syntax_and_clipped_source_cells() {
let mut diff = diff_comparison(vec![ChangedFile { let mut diff = diff_comparison(vec![ChangedFile {
review_revisions: None,
stats: None,
loaded: true, loaded: true,
before_eof: false, before_eof: false,
after_eof: false, after_eof: false,
@@ -3880,6 +3891,8 @@ fn diff_changed_backgrounds_preserve_syntax_and_clipped_source_cells() {
#[test] #[test]
fn diff_view_changes_files_focus_and_keeps_horizontal_offsets_independent() { fn diff_view_changes_files_focus_and_keeps_horizontal_offsets_independent() {
let added = ChangedFile { let added = ChangedFile {
review_revisions: None,
stats: None,
loaded: true, loaded: true,
before_eof: false, before_eof: false,
after_eof: false, after_eof: false,
@@ -4641,6 +4654,8 @@ fn large_generated_additions_render_first_scroll_and_final_rows_without_reparsin
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let hunks = crate::diff::line_hunks(&[], false, &after, true); let hunks = crate::diff::line_hunks(&[], false, &after, true);
let mut diff = diff_comparison(vec![ChangedFile { let mut diff = diff_comparison(vec![ChangedFile {
review_revisions: None,
stats: None,
path: "parser.c".into(), path: "parser.c".into(),
old_path: None, old_path: None,
status: FileStatus::Added, status: FileStatus::Added,
@@ -4690,6 +4705,8 @@ fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() {
assert!(screen_text(&terminal).contains("No changed files")); assert!(screen_text(&terminal).contains("No changed files"));
assert!(screen_text(&terminal).contains("Explorer · Tree")); assert!(screen_text(&terminal).contains("Explorer · Tree"));
let binary = diff_comparison(vec![ChangedFile { let binary = diff_comparison(vec![ChangedFile {
review_revisions: None,
stats: None,
loaded: true, loaded: true,
before_eof: false, before_eof: false,
after_eof: false, after_eof: false,
@@ -4704,6 +4721,8 @@ fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() {
render_diff(&mut terminal, &binary, &mut panels); render_diff(&mut terminal, &binary, &mut panels);
assert!(screen_text(&terminal).contains("Binary file")); assert!(screen_text(&terminal).contains("Binary file"));
let blank = diff_comparison(vec![ChangedFile { let blank = diff_comparison(vec![ChangedFile {
review_revisions: None,
stats: None,
loaded: true, loaded: true,
before_eof: false, before_eof: false,
after_eof: false, after_eof: false,
@@ -4796,6 +4815,7 @@ fn immutable_diff_draw_path_preserves_document_until_explicit_close() {
editor.apply_diff_result( editor.apply_diff_result(
id, id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: std::path::PathBuf::new(), root: std::path::PathBuf::new(),
editable_paths: Default::default(), editable_paths: Default::default(),
before: "old revision".into(), before: "old revision".into(),
@@ -4929,6 +4949,17 @@ fn native_lsp_names_are_borrowed_in_either_bar_and_ignore_configured_labels() {
groups[0][0].text, groups[0][0].text,
Cow::Borrowed("server one + server two") Cow::Borrowed("server one + server two")
)); ));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(25, 1)).unwrap();
terminal
.draw(|frame| draw_chrome(frame, groups, Rect::new(0, 0, 25, 1)))
.unwrap();
assert_eq!(
pane_screen(&terminal, Rect::new(0, 0, 25, 1)),
" server one + server two "
);
for x in [0, 24] {
assert_eq!(terminal.backend().buffer()[(x, 0)].bg, Color::Cyan);
}
} }
} }
@@ -4941,6 +4972,7 @@ fn chrome_separates_visible_segments_once_in_every_anchor() {
line.left.clear(); line.left.clear();
line.center.clear(); line.center.clear();
line.right.clear(); line.right.clear();
line.segments.entry("lsp".into()).or_default().padding = 0;
let names = vec![ let names = vec![
"before".into(), "before".into(),
"lsp".into(), "lsp".into(),
@@ -5643,6 +5675,7 @@ fn comparison_titles_follow_loaded_counts_and_each_tabs_context() {
editor.apply_diff_result( editor.apply_diff_result(
id, id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(), root: directory.0.clone(),
editable_paths: Default::default(), editable_paths: Default::default(),
before: "baseline".into(), before: "baseline".into(),
@@ -5652,17 +5685,13 @@ fn comparison_titles_follow_loaded_counts_and_each_tabs_context() {
})), })),
); );
} }
let view = if phase == "view" { let changes = if phase == "view" { " +3 -2" } else { "" };
"Diff · +3 -2 · "
} else {
"Diff · "
};
let default_tabs = editor.top_bar_config().segment("tabs").clone(); let default_tabs = editor.top_bar_config().segment("tabs").clone();
for (template, ordinary, prefix, uses_view) in [ for (template, ordinary, prefix, uses_view) in [
(None, "1 source.rs", "2 ", true), (None, "1 source.rs", "2 ", true),
(Some("{index} {name}{modified}"), "1 source.rs", "2 ", false), (Some("{index} {name}{modified}"), "1 source.rs", "2 ", false),
( (
Some("T{index}:{view}{name}{modified}"), Some("T{index}:{view}{name}{changes}{modified}"),
"T1:source.rs", "T1:source.rs",
"T2:", "T2:",
true, true,
@@ -5676,7 +5705,8 @@ fn comparison_titles_follow_loaded_counts_and_each_tabs_context() {
bottom.left = vec!["mode".into()]; bottom.left = vec!["mode".into()];
bottom.center.clear(); bottom.center.clear();
bottom.right.clear(); bottom.right.clear();
bottom.segments.get_mut("mode").unwrap().title = "Current:{view}{name}".into(); bottom.segments.get_mut("mode").unwrap().title =
"Current:{view}{name}{changes}".into();
}); });
for comparison_active in [true, false] { for comparison_active in [true, false] {
let mut terminal = let mut terminal =
@@ -5697,16 +5727,33 @@ fn comparison_titles_follow_loaded_counts_and_each_tabs_context() {
.unwrap(); .unwrap();
let titles = pane_screen(&terminal, Rect::new(0, 0, 160, 1)); let titles = pane_screen(&terminal, Rect::new(0, 0, 160, 1));
assert!(titles.contains(ordinary), "{phase}: {titles}"); assert!(titles.contains(ordinary), "{phase}: {titles}");
let comparison = format!("{prefix}{}source.rs", if uses_view { view } else { "" }); let comparison = if uses_view {
format!("{prefix}Diff · source.rs{changes}")
} else {
format!("{prefix}source.rs")
};
assert!(titles.contains(&comparison), "{phase}: {titles}"); assert!(titles.contains(&comparison), "{phase}: {titles}");
assert_eq!( assert_eq!(
titles.matches("Diff · ").count(), titles.matches("Diff · ").count(),
usize::from(uses_view), usize::from(uses_view),
"{phase}: {titles}" "{phase}: {titles}"
); );
if matches!(phase, "view" | "unloaded") && comparison_active {
let total = if phase == "view" {
"Diff +3 -2"
} else {
"Diff …"
};
assert!(titles.contains(total), "{phase}: {titles}");
} else {
assert!(
!titles.contains("Diff +") && !titles.contains("Diff …"),
"{phase}: {titles}"
);
}
let current = pane_screen(&terminal, Rect::new(0, 1, 160, 1)); let current = pane_screen(&terminal, Rect::new(0, 1, 160, 1));
let expected = if comparison_active { let expected = if comparison_active {
format!("Current:{view}source.rs") format!("Current:Diff · source.rs{changes}")
} else { } else {
"Current:source.rs".into() "Current:source.rs".into()
}; };
@@ -5733,25 +5780,40 @@ fn comparison_title_counts_follow_live_edits_and_undo_in_hidden_tabs() {
editor.handle(Input::Enter); editor.handle(Input::Enter);
editor.handle(Input::Enter); editor.handle(Input::Enter);
let (id, _) = editor.take_diff_request().unwrap(); let (id, _) = editor.take_diff_request().unwrap();
let mut unopened = syntax_diff_file("unopened.rs", "", "");
unopened.loaded = false;
unopened.before.clear();
unopened.after.clear();
unopened.hunks.clear();
unopened.stats = Some((7, 4));
editor.apply_diff_result( editor.apply_diff_result(
id, id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(), root: directory.0.clone(),
editable_paths: ["live.rs".into()].into(), editable_paths: ["live.rs".into()].into(),
before: "baseline".into(), before: "baseline".into(),
after: "Working checkout".into(), after: "Working checkout".into(),
revisions: ["before-snapshot".into(), "after-snapshot".into()], revisions: ["before-snapshot".into(), "after-snapshot".into()],
files: vec![syntax_diff_file("live.rs", "old", "new")], files: vec![syntax_diff_file("live.rs", "old", "new"), unopened],
})), })),
); );
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap();
let mut views = BufferViewports::default();
for input in [Input::Down, Input::Char('l'), Input::Ctrl('u'), Input::Home] {
editor.handle(input);
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let titles = pane_screen(&terminal, Rect::new(0, 0, 120, 1));
assert!(titles.contains("Diff +8 -5"), "{titles}");
}
editor.handle(Input::Char('\t')); editor.handle(Input::Char('\t'));
editor.handle(Input::Char('\t')); editor.handle(Input::Char('\t'));
assert!(editor.diff_editing()); assert!(editor.diff_editing());
editor.execute("tab.new"); editor.execute("tab.new");
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); for (inputs, counts, total) in [
let mut views = BufferViewports::default(); (vec![], "+1 -1", "+8 -5"),
for (inputs, counts) in [
(vec![], "+1 -1"),
( (
vec![ vec![
Input::Char('i'), Input::Char('i'),
@@ -5759,9 +5821,10 @@ fn comparison_title_counts_follow_live_edits_and_undo_in_hidden_tabs() {
Input::Escape, Input::Escape,
], ],
"+2 -1", "+2 -1",
"+9 -5",
), ),
(vec![Input::Char('u')], "+1 -1"), (vec![Input::Char('u')], "+1 -1", "+8 -5"),
(vec![Input::Char('U')], "+2 -1"), (vec![Input::Char('U')], "+2 -1", "+9 -5"),
] { ] {
for input in inputs { for input in inputs {
editor.handle(input); editor.handle(input);
@@ -5772,10 +5835,18 @@ fn comparison_title_counts_follow_live_edits_and_undo_in_hidden_tabs() {
.unwrap(); .unwrap();
let titles = pane_screen(&terminal, Rect::new(0, 0, 120, 1)); let titles = pane_screen(&terminal, Rect::new(0, 0, 120, 1));
assert!( assert!(
titles.contains(&format!("1 Diff · {counts} · live.rs")), titles.contains(&format!("1 Diff · live.rs {counts}")),
"{titles}" "{titles}"
); );
assert!(titles.contains("2 live.rs"), "{titles}"); assert!(titles.contains("2 live.rs"), "{titles}");
assert!(!titles.contains("Diff +"), "{titles}");
editor.execute("tab.previous");
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let titles = pane_screen(&terminal, Rect::new(0, 0, 120, 1));
assert!(titles.contains(&format!("Diff {total}")), "{titles}");
editor.execute("tab.next");
} }
} }
@@ -6368,6 +6439,7 @@ fn both_bars_bound_palettes_diff_selectors_docks_and_diff_prompts() {
editor.apply_diff_result( editor.apply_diff_result(
id, id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(), root: directory.0.clone(),
editable_paths: Default::default(), editable_paths: Default::default(),
before: "baseline".into(), before: "baseline".into(),
@@ -6446,6 +6518,260 @@ fn narrow_tab_bar_keeps_the_active_title_visible_and_handles_empty_areas() {
assert!(titles.contains("2 [No Name]"), "{titles}"); assert!(titles.contains("2 [No Name]"), "{titles}");
} }
#[test]
fn inline_blame_is_visible_in_focused_diff_buffers() {
for unified in [false, true] {
for before in [false, true] {
let mut editor = readonly_diff_editor(deletion_file());
if unified {
editor.execute("diff.view.toggle");
}
editor.handle(Input::Char('\t'));
if editor.diff_buffer_focus() != Some(if before { Focus::Before } else { Focus::After })
{
editor.handle(Input::Char('\t'));
}
let source = editor.blame_source().unwrap();
let blame = Arc::new(crate::blame::LineBlame {
commit: "0123456789abcdef".into(),
author: "Author".into(),
summary: "Original".into(),
uncommitted: false,
});
editor.set_line_blame(source.clone(), blame.clone());
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 12)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let screen = screen_text(&terminal);
assert!(screen.contains("Author · Original (01234567)"), "{screen}");
let caret = terminal.get_cursor_position().unwrap();
assert!(pane_screen(&terminal, Rect::new(0, caret.y, 180, 1)).contains("Author"));
editor.handle(Input::Char('\t'));
assert!(!editor.set_line_blame(source, blame));
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(!screen_text(&terminal).contains("Author · Original"));
}
}
}
#[test]
fn inline_blame_remains_visible_on_lines_with_room_for_partial_metadata() {
let directory = Directory::new();
for (context, width) in [("document", 80), ("split", 180), ("unified", 120)] {
let text = "short\nlet meaningful_identifier = call_with_arguments();\ntail";
let mut editor = if context == "document" {
Editor::open(Some(directory.file("file.txt", text)), vec![]).unwrap()
} else {
let mut editor = readonly_diff_editor(syntax_diff_file("file.txt", text, "changed"));
if context == "unified" {
editor.execute("diff.view.toggle");
}
editor.handle(Input::Char('\t'));
editor
};
let source = editor.blame_source().unwrap();
let blame = Arc::new(crate::blame::LineBlame {
commit: "0123456789abcdef".into(),
author: "界 e\u{301} Author".into(),
summary: "A detailed commit summary describing the source change".into(),
uncommitted: false,
});
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 12)).unwrap();
let mut views = BufferViewports::default();
for row in 0..3 {
assert_eq!(editor.cursor().row, row);
editor.set_cached_line_blame(&source, blame.clone());
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let caret = terminal.get_cursor_position().unwrap();
let line = pane_screen(&terminal, Rect::new(0, caret.y, width, 1));
assert!(
line.contains("Author") && line.contains("e\u{301}"),
"{context}, source row={row}: {line}"
);
assert!(line.contains(editor.lines()[row].as_str()), "{line}");
editor.handle(Input::Down);
}
}
}
#[test]
fn diff_blame_uses_final_wrap_and_survives_a_historical_tab() {
for unified in [false, true] {
let text = "x".repeat(200);
let mut editor = readonly_diff_editor(syntax_diff_file("file.txt", &text, "after"));
if unified {
editor.execute("diff.view.toggle");
}
editor.handle(Input::Char('\t'));
editor.execute("word_wrap.toggle");
let source = editor.blame_source().unwrap();
let blame = Arc::new(crate::blame::LineBlame {
commit: String::new(),
author: String::new(),
summary: String::new(),
uncommitted: true,
});
editor.set_line_blame(source.clone(), blame.clone());
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 14)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let caret = terminal.get_cursor_position().unwrap();
let screen = screen_text(&terminal);
assert_eq!(screen.matches("Uncommitted").count(), 1, "{screen}");
assert!(!pane_screen(&terminal, Rect::new(0, caret.y, 180, 1)).contains("Uncommitted"));
let mut narrow = Terminal::new(ratatui::backend::TestBackend::new(12, 14)).unwrap();
narrow.draw(|frame| views.draw(frame, &mut editor)).unwrap();
assert!(!screen_text(&narrow).contains("Uncommitted"));
editor.execute("tab.new");
assert!(editor.diff().is_none());
if !editor.word_wrap() {
editor.execute("word_wrap.toggle");
}
editor.set_cached_line_blame(&source, blame);
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(screen_text(&terminal).contains("Uncommitted"));
editor.handle(Input::Char('i'));
editor.handle(Input::Char('z'));
assert_eq!(editor.lines().join("\n"), text);
}
}
#[test]
fn inline_blame_loads_real_historical_and_working_diff_sources() {
let directory = Directory::new();
let git = |args: &[&str]| {
let output = std::process::Command::new("git")
.current_dir(&directory.0)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_AUTHOR_NAME", "Author")
.env("GIT_AUTHOR_EMAIL", "author@example.test")
.env("GIT_COMMITTER_NAME", "Author")
.env("GIT_COMMITTER_EMAIL", "author@example.test")
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_owned()
};
git(&["init", "-q", "--template="]);
let path = directory.file("file.txt", "original\n");
git(&["add", "file.txt"]);
git(&["commit", "-qm", "Original"]);
let original = git(&["rev-parse", "HEAD"]);
directory.file("file.txt", "historical\n");
git(&["commit", "-qam", "Historical"]);
let historical = git(&["rev-parse", "HEAD"]);
git(&["rm", "-q", "file.txt"]);
git(&["commit", "-qm", "Deleted"]);
let config = directory.file(
".git/vamp-config.rb",
"Vamp.configure { |c| c.git_gutter = 'none' }",
);
let mut session = EditorSession::open(Some(path.clone()), vec![], Some(&config)).unwrap();
let ready = |session: &mut EditorSession, predicate: &dyn Fn(&Editor) -> bool| {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !predicate(session.editor()) {
assert!(
std::time::Instant::now() < deadline,
"message={:?}, state={:?}, blame={:?}, text={:?}",
session.editor().message(),
session.editor().diff().map(|diff| (
diff.phase,
diff.focus,
diff.file,
&diff.error
)),
session.editor().line_blame(),
session.editor().lines()
);
session.poll();
std::thread::sleep(Duration::from_millis(1));
}
};
session.execute("diff");
ready(&mut session, &|editor| {
editor.diff().is_some_and(|diff| diff.catalog.is_some())
});
for revision in [&original, &historical] {
for ch in revision.chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Down);
session.handle(Input::Enter);
}
ready(&mut session, &|editor| {
editor
.diff()
.is_some_and(|diff| diff.selected_file_loaded())
});
session.handle(Input::Char('\t'));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 12)).unwrap();
let mut views = BufferViewports::default();
for expected in ["Original", "Historical"] {
ready(&mut session, &|editor| {
editor
.line_blame()
.is_some_and(|label| label.contains(expected))
});
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
let screen = screen_text(&terminal);
assert!(screen.contains(&format!("Author · {expected}")), "{screen}");
assert!(!screen.contains("Uncommitted"), "{screen}");
session.handle(Input::Char('\t'));
}
assert!(!path.exists());
session.execute("diff.close");
directory.file("file.txt", "restored\n");
git(&["add", "file.txt"]);
git(&["commit", "-qm", "Restored"]);
directory.file("file.txt", "unsaved baseline\n");
session.editor_mut().refresh_files();
session.execute("diff");
ready(&mut session, &|editor| {
editor.diff().is_some_and(|diff| diff.catalog.is_some())
});
for ch in git(&["rev-parse", "HEAD"]).chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Down);
session.handle(Input::Enter);
session.handle(Input::Enter);
ready(&mut session, &|editor| {
editor
.diff()
.is_some_and(|diff| diff.selected_file_loaded())
});
session.handle(Input::Char('\t'));
session.handle(Input::Char('\t'));
ready(&mut session, &|editor| {
editor.line_blame() == Some("Uncommitted")
});
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert!(screen_text(&terminal).contains("Uncommitted"));
assert_eq!(fs::read_to_string(path).unwrap(), "unsaved baseline\n");
}
#[test] #[test]
fn inline_blame_sanitizes_metadata_preserves_text_and_hides_stale_labels() { fn inline_blame_sanitizes_metadata_preserves_text_and_hides_stale_labels() {
let directory = Directory::new(); let directory = Directory::new();
@@ -6536,11 +6862,6 @@ fn inline_blame_uses_last_wrap_continuation_and_never_overflows_narrow_panes() {
.draw(|frame| views.draw(frame, &mut editor)) .draw(|frame| views.draw(frame, &mut editor))
.unwrap(); .unwrap();
assert!(!screen_text(&terminal).contains("Uncommitted")); assert!(!screen_text(&terminal).contains("Uncommitted"));
let mut historical = readonly_diff_editor(deletion_file());
assert!(historical.blame_source().is_none());
historical.execute("tab.new");
assert!(historical.diff().is_none());
assert!(historical.blame_source().is_none());
} }
#[test] #[test]
@@ -6786,6 +7107,7 @@ fn custom_value_stays_above_split_overlays_and_diff_geometry() {
assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)), title); assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)), title);
let mut editor = readonly_diff_editor(deletion_file()); let mut editor = readonly_diff_editor(deletion_file());
configure_chrome(&mut editor, |top, _| top.right = vec!["battery".into()]);
terminal terminal
.draw(|frame| views.draw(frame, &mut editor)) .draw(|frame| views.draw(frame, &mut editor))
.unwrap(); .unwrap();
@@ -6971,6 +7293,7 @@ fn native_diff_dock_and_panes_fill_the_workspace_below_only_configured_chrome()
for (width, height) in [(120, 30), (32, 9)] { for (width, height) in [(120, 30), (32, 9)] {
for chrome in [false, true] { for chrome in [false, true] {
let mut editor = readonly_diff_editor(deletion_file()); let mut editor = readonly_diff_editor(deletion_file());
configure_chrome(&mut editor, |top, _| top.right = vec!["battery".into()]);
if chrome { if chrome {
editor.set_label("battery".into(), "live".into()).unwrap(); editor.set_label("battery".into(), "live".into()).unwrap();
} }
@@ -7773,6 +8096,7 @@ fn deferred_lsp_definition_centers_only_successful_current_diff_results() {
editor.apply_diff_result( editor.apply_diff_result(
id, id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(), root: directory.0.clone(),
editable_paths: Default::default(), editable_paths: Default::default(),
before: "baseline".into(), before: "baseline".into(),
@@ -7871,6 +8195,13 @@ fn install_readonly_diff(editor: &mut Editor, file: ChangedFile) {
editor.apply_diff_result( editor.apply_diff_result(
id, id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: std::array::from_fn(|_| {
Some(crate::diff::BlameRevision {
store: editor.project_root().join(".git"),
commit: "0123456789abcdef0123456789abcdef01234567".into(),
keepalive: None,
})
}),
root: editor.project_root().to_path_buf(), root: editor.project_root().to_path_buf(),
editable_paths: Default::default(), editable_paths: Default::default(),
before: "baseline".into(), before: "baseline".into(),
@@ -8723,6 +9054,7 @@ fn diff_semantic_colors_stay_buffer_local_across_focus_changes() {
editor.apply_diff_result( editor.apply_diff_result(
id, id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(), root: directory.0.clone(),
editable_paths: if writable { editable_paths: if writable {
[std::path::PathBuf::from("live.rs")].into() [std::path::PathBuf::from("live.rs")].into()
@@ -8853,6 +9185,7 @@ fn editable_after_renders_live_syntax_caret_visual_selection_and_command_prompt(
editor.apply_diff_result( editor.apply_diff_result(
id, id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(), root: directory.0.clone(),
editable_paths: [std::path::PathBuf::from("live.rs")].into(), editable_paths: [std::path::PathBuf::from("live.rs")].into(),
before: "baseline".into(), before: "baseline".into(),