Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c12d98e348 | |||
| e6d94dcb41 |
@@ -1,5 +1,3 @@
|
||||
- [x] feature: syntax/lsp - add a default configuration for typst.
|
||||
- for the lsp, i'm thinking tinymist is a good candidate.
|
||||
- [ ] feature: diff view - GitHub code review integration after the local review UX is usable.
|
||||
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
|
||||
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
|
||||
|
||||
@@ -20,7 +20,7 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
|
||||
- `review`: owns the validated local review model, exact immutable anchors, persisted file checkmarks, SQLite storage through bundled rusqlite, and atomic JSON import/export with external-change protection. Schema 3 stores review identity, repository, revision, summary, and verdict in `reviews`; `comments` stores ordered comment IDs, bodies, resolution states, and explicit immutable anchor fields. `reviewed_files` stores revision pairs, paths, optional old paths, and optional exact live After text. Foreign keys, unique keys, and checks enforce relationships and scalar invariants. Full `u64` comment IDs use checked decimal text because SQLite integers are signed. No JSON documents are stored in SQLite.
|
||||
`Store` retains compare-and-swap review revisions and atomic whole-review saves: metadata and replacement comment/checkmark rows commit together. Read transactions reconstruct validated reviews from one consistent snapshot, including when listing in descending ID order. Stored text is bounded to 64 MiB per review, including a fixed per-comment/checkmark overhead. JSON version 4 is an independent import/export format. Incompatible databases, including schemas 1 and 2, are rejected and preserved without migration or reset. CLI metadata, sole-review restoration, and selector labels reuse loaded reviews; selector choices live only in the displayed buffer.
|
||||
The review CLI keeps `list`/`show` read-only. Comment deletion validates the explicit review/comment IDs through an existing read-only store before opening it for a compare-and-swap save; absent storage and invalid targets do not create reviews or a database.
|
||||
`editor_review` commits native comment/summary buffers on dismissal or switching, and verdicts/deletions immediately. It restores a sole stored review or opens the floating selector for multiple reviews. Exports retain their own file baseline and do not become automatic persistence targets; imports get fresh database identities. Review editors remain tab-owned native panes outside the workspace `PaneTree`. Storage opens lazily when a comparison is ready; the session supplies the configured or XDG default directory. Direct `Editor` callers inject a directory with `set_review_directory` to enable persistence.
|
||||
`editor_review` owns tab-local review state independently of comparisons. Ordinary repository source buffers and diff panes share the comment editor, immutable anchors, storage, and review commands. Comment/summary text saves explicitly; dismissing discards that editor's unsaved text, while switching may retain hidden drafts. Verdicts/deletions/resolution changes commit immediately without saving drafts. It restores a sole stored review or opens the floating selector for multiple reviews. Exports retain their own file baseline and do not become automatic persistence targets; imports get fresh database identities. Review editors remain tab-owned native panes outside the workspace `PaneTree`. The session supplies the configured or XDG default directory; direct `Editor` callers inject a directory with `set_review_directory` to enable persistence.
|
||||
Successful SQLite autosaves and JSON-only saves share one acknowledgement operation: clear review dirtiness and advance the owning tab's native draft saved baselines only after persistence succeeds. Failed writes retain unsaved state; JSON exports do not acknowledge a pending SQLite save.
|
||||
Review replacement and comment deletion are private editor-owned operations. Replacement loads and validates its Stored/JSON/New source before retiring old bindings, installing the document, and resetting selection/navigation. Deletion retires and acknowledges only the matching comment drafts, preventing stale text from overwriting reused IDs. Both persist before opening the overview; a post-mutation save failure retains the applied, unsaved identity change. Native text/undo and general transition guards remain unchanged.
|
||||
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
|
||||
|
||||
@@ -259,8 +259,8 @@ Normal and Visual Ctrl+u/Ctrl+d invoke `config.core.scroll_step_up` / `config.co
|
||||
| Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` |
|
||||
| Normal | `follow_link` |
|
||||
| Normal | `diff`, `diff.mode.toggle`, `diff.view.toggle`, `diff.orientation.toggle`, `diff.review.toggle` |
|
||||
| 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.copy`, `diff.review.summary`, `diff.review.verdict`, `diff.review.save`, `diff.review.open`, `diff.review.new` |
|
||||
| Normal, Visual | `comment` |
|
||||
| Normal | `review.edit`, `review.delete`, `review.resolve`, `review.unresolve`, `review.next`, `review.previous`, `review.overview`, `review.copy`, `review.summary`, `review.verdict`, `review.save`, `review.open`, `review.new` |
|
||||
| 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, Insert, Visual, Command, Search | `word_wrap.toggle` |
|
||||
@@ -346,16 +346,16 @@ Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work
|
||||
|
||||
`config.core.diff.review.toggle` is a non-mutating Normal-mode action, also available as `:diff.review.toggle` and in the palette. It marks/unmarks the highlighted changed-file leaf when the comparison's explorer is focused, or the current comparison file from a text pane. Read-only snapshots permit review marks. The default `c` shortcut applies only while browsing the diff's changed-file tree; explicit bindings and disabled keys override it. For example, `config.bind "ctrl+g", config.core.diff.review.toggle`; use the palette or a control-key binding while filtering, including List view, so printable input stays in the query.
|
||||
|
||||
Local comments use `config.core.diff.comment` in Normal or Visual mode; the remaining `config.core.diff.review` actions in the table above use Normal mode. They have no default bindings and are available through the palette and canonical Ex commands. `edit`, `delete`, `resolve`, `unresolve`, `verdict`, `save`, and `open` open argument prompts when selected as actions. Comment IDs are optional and default to the overview entry under the caret or selected comment. For example:
|
||||
Local comments use `config.core.comment` in Normal or Visual mode in ordinary repository source buffers and diff text panes; the remaining `config.core.review` actions in the table above use Normal mode. File checkmarks use `config.core.diff.review.toggle`. These actions have no default bindings and are available through the palette and Ex commands. `edit`, `delete`, `resolve`, `unresolve`, `verdict`, `save`, and `open` open argument prompts when selected as actions. Comment IDs are optional and default to the overview entry under the caret or selected comment. For example:
|
||||
|
||||
```ruby
|
||||
config.bind "leader r c", config.core.diff.comment
|
||||
config.bind "leader r c", config.core.diff.comment, mode: config.modes.visual
|
||||
config.bind "leader r o", config.core.diff.review.overview
|
||||
config.bind "leader r n", config.core.diff.review.next
|
||||
config.bind "leader r c", config.core.comment
|
||||
config.bind "leader r c", config.core.comment, mode: config.modes.visual
|
||||
config.bind "leader r o", config.core.review.overview
|
||||
config.bind "leader r n", config.core.review.next
|
||||
```
|
||||
|
||||
Review editing uses a floating window without adding workspace splits. Dismissing or switching editors commits text to local SQLite; verdicts, deletions, and resolution changes commit immediately. `:w` commits explicitly; `:diff.review.save path` exports JSON without redirecting autosaves. `config.review_directory` defaults to `nil`, selecting `$XDG_DATA_HOME/vamp/reviews/` (absolute XDG path only), otherwise `~/.local/share/vamp/reviews/`. String overrides are resolved relative to the configuration file; empty/NUL-containing strings and non-string/non-nil values are rejected. These actions do not contact a forge. See [local review behavior](editing.md#repository-diff).
|
||||
Review editing uses a floating window without adding workspace splits. `:w` or `:review.save` commits comment/summary text, including hidden drafts; dismissing an editor discards its unsaved text, and switching editors does not save it. Verdicts, deletions, and resolution changes commit immediately without saving drafts. `:review.save path` additionally exports JSON without redirecting autosaves. `config.review_directory` defaults to `nil`, selecting `$XDG_DATA_HOME/vamp/reviews/` (absolute XDG path only), otherwise `~/.local/share/vamp/reviews/`. String overrides are resolved relative to the configuration file; empty/NUL-containing strings and non-string/non-nil values are rejected. These actions do not contact a forge. See [local review behavior](editing.md#repository-diff).
|
||||
|
||||
|
||||
`config.core.pane.close` and `config.core.pane.force_close` close a focused **docked** file list instead of its editor pane, without discarding buffers or exiting the comparison. A floating picker does not intercept these actions: normal close still protects underlying dirty buffers, while force-close retains its existing discard semantics. For example, bind `config.bind "ctrl+x", config.core.pane.close`; focused tree/selection overrides and printable filter input retain priority. Palette-originated close actions retain the originating dock target through force confirmation.
|
||||
|
||||
+3
-1
@@ -67,7 +67,7 @@ The two skipped shell cleanup checks execute `/bin/ps`, which macOS refuses to l
|
||||
- Prompt-history checks cover separate session-wide command/search entries, Unicode draft restoration, editable recalls, clamped/empty/bounded history, duplicate/empty/cancelled submissions, remapped/disabled keys, and preserved Visual/read-only diff targets. Live PTY smoke exercises arrows and raw Ctrl+k/j, restores drafts, accepts a recalled search with Enter, and checks command/search isolation and unchanged source bytes.
|
||||
- Command-completion checks cover forward/backward alphabetical cycling/wrap across core names, aliases and live plugin registrations, reverse-first completion and direction changes retaining the prefix, explicit execution, edits/history/cancel resets, empty/unknown/Unicode prefixes, untouched arguments, remapped/disabled keys, and preserved Visual/read-only diff targets and literal search tabs. Live PTY smoke cycles `tab.*` in both directions through wraparound, submits a completed `tab.new`, and exits with unchanged source bytes.
|
||||
- Shell-command checks use file-gated subprocesses to prove editing remains live, stdout/stderr/status and cwd handling, null stdin, output bounds, launch/worker errors, and process-group cleanup on completion/drop. Spinner timing uses an injected `Instant`; render checks cover scrolling, dismissal, hidden source caret, and tiny/offset windows. Live PTY smoke gates completion, saves an edit while pending, then observes both output streams and exit status and dismisses the popup.
|
||||
- Visual-command checks preserve directed characterwise/linewise ranges and multiple carets through command cancellation and plugin dispatch, exercise remapped command entry, and submit `:diff.comment` on either historical side. Live PTY smoke selects lines, cancels a prompt, then runs `:diff.comment` directly and verifies the saved original range without using the palette.
|
||||
- Visual-command checks preserve directed characterwise/linewise ranges and multiple carets through command cancellation and plugin dispatch, exercise remapped command entry, and submit `:comment` on either historical side. Live PTY smoke selects lines, cancels a prompt, then runs `:comment` directly and verifies the saved original range without using the palette.
|
||||
- Presentation regressions render retained characterwise/linewise highlights during command entry in ordinary, split-diff, and unified views. Floating-review checks preserve source rectangles and the workspace layout, place the caret inside the overlay, retain explicitly saved text across tab switches, and restore source focus on Escape or pane close.
|
||||
- Review prose checks render long comments and summaries with wrapping enabled by default in split/unified views, preserve exact single-line text and source wrapping, and retain an explicit wrap toggle on reopening. Live PTY smoke displays a long comment across continuation rows, saves it, verifies exact text through the review CLI, and reopens it still wrapped.
|
||||
- Precise-range checks cover forward/reversed characterwise and partial-multiline selections on both sides, linewise and caret/empty-line fallbacks, source-column navigation, Unicode grapheme bounds, invalid schemas, and preserved save-conflict checks. Render checks cover exact text/newline cells, wrapping, horizontal clipping, shared unified context, stale suppression, and native Visual precedence. Live PTY smoke saves and reopens a selection containing a wide and combining grapheme.
|
||||
@@ -98,6 +98,8 @@ Diff-stat checks cover unopened frozen files, additions/deletions/renames, binar
|
||||
|
||||
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.
|
||||
|
||||
Ordinary-source review checks cover unsaved Unicode selections, line/empty-line anchors, no storage creation on initialization, explicit save/discard, optimistic conflicts, tab/pane guards, JSON round-trips, stale read-only snapshots, and rendered exact-range markers. Live PTY smoke selects a source line, creates a comment with `:comment`, saves with `:w`, verifies its exact excerpt through the review CLI, restarts with `:review.next`, and reopens after changing the selected source to observe the preserved original snapshot. Review state is tab-owned rather than comparison-owned; diff regression coverage remains required.
|
||||
|
||||
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.
|
||||
|
||||
+24
-20
@@ -196,7 +196,7 @@ Up/Ctrl+k recalls older entries; Down/Ctrl+j recalls newer entries and restores
|
||||
|
||||
Tab replaces the command-name prefix with the first case-sensitive match in alphabetical order; repeated presses cycle forward through the same matches and wrap. Shift+Tab cycles backward, starting at the last match when no cycle is active. Switching direction retains the original prefix. Candidates include canonical core commands, shorthand aliases, and commands from currently enabled plugins. Empty input cycles all command names. Typing, deleting, or browsing history starts a fresh completion cycle. Completion only changes the prompt: Enter still executes, and Escape cancels. Arguments (including paths) are not completed; Tab and Shift+Tab leave them unchanged. Search-mode Tab remains literal text. Rebind or disable forward/backward completion with `config.core.command_complete` / `config.core.command_complete_previous` in Command mode.
|
||||
|
||||
Command input works in both Normal and Visual modes, including read-only diff panes. The original selection stays highlighted while typing. Enter dispatches with its ranges, direction, and linewise shape intact; individual commands decide whether to consume or retain them. Escape returns to the original Visual selection without executing. For example, select lines with `V`, then type `:diff.comment` and press Enter—no command palette is needed. `config.core.command` can also be bound in Visual mode.
|
||||
Command input works in both Normal and Visual modes, including read-only diff panes. The original selection stays highlighted while typing. Enter dispatches with its ranges, direction, and linewise shape intact; individual commands decide whether to consume or retain them. Escape returns to the original Visual selection without executing. For example, select lines with `V`, then type `:comment` and press Enter—no command palette is needed. `config.core.command` can also be bound in Visual mode.
|
||||
|
||||
Use `:!COMMAND` (for example `:!pwd` or `:!echo "$PATH"`) to run shell commands asynchronously while continuing to edit. Vamp runs `$SHELL -c COMMAND`, falling back to `/bin/sh`, in the editor's project directory with the inherited environment and closed stdin. Shell quoting, pipes, and redirection work normally; interactive terminal programs are not supported. Commands have the same permissions as Vamp, including file and network access. Submit additional `:!` commands while others are running to execute them concurrently.
|
||||
|
||||
@@ -480,37 +480,39 @@ 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.
|
||||
|
||||
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.
|
||||
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 `: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** work in ordinary repository source buffers and diff text panes; they share storage with file-reviewed checkmarks and never publish to GitHub. Run **`: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. Multiple independent carets/ranges must be reviewed separately.
|
||||
|
||||
Comments, summaries, and the review overview open in a focused floating editor over the whole diff text workspace, not a source-side split. The source layout, file explorer, and viewports stay unchanged. Normal editing, selections, and undo/redo work in editable review buffers. Escape in Normal mode or `:pane.close` dismisses the window and returns to the diff, discarding that editor's unsaved text. An unsaved new comment is not attached to the review; an existing comment or summary reopens with its last saved text and no discarded undo history. Insert/Visual/Command Escape first retains its usual modal behavior. Source rows are never replaced with comment text. Magenta **`○`** gutter markers identify saved comment ranges; **`●`** identifies the selected comment. Its exact current text range has a magenta background, including wrapped continuations and selected newline cells; native Visual selections take precedence. Stale comments have neither markers nor text highlighting. Markers preserve addition/deletion signs, appear only on the first wrapped segment, and also work in unified view.
|
||||
Comments, summaries, and the review overview open in a focused floating editor over the source workspace, not a source-side split. The source layout, file explorer, and viewports stay unchanged. Normal editing, selections, and undo/redo work in editable review buffers. Escape in Normal mode or `:pane.close` dismisses the window and returns to the source, discarding that editor's unsaved text. An unsaved new comment is not attached to the review; an existing comment or summary reopens with its last saved text and no discarded undo history. Insert/Visual/Command Escape first retains its usual modal behavior. Source rows are never replaced with comment text. Magenta **`○`** gutter markers identify saved comment ranges; **`●`** identifies the selected comment. Its exact current text range has a magenta background, including wrapped continuations and selected newline cells; native Visual selections take precedence. Stale comments have neither markers nor text highlighting. Markers preserve addition/deletion signs, appear only on the first wrapped segment, and also work in unified view.
|
||||
|
||||
Local review buffers (comments, summaries, overview, and stored-review selector) soft-wrap by default without inserting newlines into saved text. Use `:word_wrap.toggle` to disable wrapping for the focused review buffer; reopening it retains that choice for the session. Source diff wrapping remains independent.
|
||||
|
||||
| Command | Effect |
|
||||
| --- | --- |
|
||||
| `:diff.comment` | Add a comment on the current line or exact selected text range, on the focused side |
|
||||
| `:diff.review.edit [id]` | Edit an existing comment; defaults to the overview entry under the caret or selected comment |
|
||||
| `:diff.review.delete [id]` | Delete that draft comment |
|
||||
| `: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.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.verdict looks_good` | Record **Looks good** |
|
||||
| `:diff.review.verdict changes_requested` | Record **Changes requested**; `none` clears the verdict |
|
||||
| `:diff.review.save [path]` | Commit to SQLite; with a path, also export JSON for sharing |
|
||||
| `:diff.review.open [id or path]` | Show stored reviews, open a stored review ID, or import a JSON review as a new review |
|
||||
| `:diff.review.new` | Create a separate stored review for the current repository |
|
||||
| `:comment` | Add a comment on the current source line or exact selected text range |
|
||||
| `:review.edit [id]` | Edit an existing comment; defaults to the overview entry under the caret or selected comment |
|
||||
| `:review.delete [id]` | Delete that draft comment |
|
||||
| `:review.resolve [id]` / `:review.unresolve [id]` | Mark a comment resolved/unresolved; uses the same default selection as edit/delete |
|
||||
| `:review.next` / `:review.previous` | Cycle comments, opening their text and revealing a matching source anchor |
|
||||
| `:review.overview` | Refresh/open a read-only summary and comment list; Enter edits the entry under the caret |
|
||||
| `:review.copy` | Copy all saved comments in the selected review to the configured clipboard, using the CLI text format |
|
||||
| `:review.summary` | Compose the review summary |
|
||||
| `:review.verdict looks_good` | Record **Looks good** |
|
||||
| `:review.verdict changes_requested` | Record **Changes requested**; `none` clears the verdict |
|
||||
| `:review.save [path]` | Commit to SQLite; with a path, also export JSON for sharing |
|
||||
| `:review.open [id or path]` | Show stored reviews, open a stored review ID, or import a JSON review as a new review |
|
||||
| `:review.new` | Create a separate stored review for the current repository |
|
||||
|
||||
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.
|
||||
File checkmarks remain diff-specific (`:diff.review.toggle`).
|
||||
|
||||
`: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.
|
||||
Comment and summary text is committed only by **`:w`** or **`: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. `: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.
|
||||
|
||||
`: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.
|
||||
|
||||
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 `:review.open id`. Use `:review.new` for another independent review.
|
||||
|
||||
Write errors, locked databases, incompatible schemas, and conflicting edits from another editor instance retain dirty drafts and report the failure. Failed text saves leave the committed review unchanged; unrelated metadata autosaves cannot commit those drafts. Normal destructive closes remain guarded until drafts are explicitly saved or discarded. `:w!` does not bypass database revision conflicts; export your draft before reopening the current stored review. JSON exports retain external-file-change protection. Only the current database schema is accepted; existing incompatible databases are preserved rather than reset or migrated.
|
||||
|
||||
@@ -520,6 +522,8 @@ Version 4 JSON is the agent-readable sharing format: repository root, summary, o
|
||||
|
||||
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.
|
||||
|
||||
Ordinary source comments use an immutable content snapshot identity and capture the current buffer, including unsaved text; they do not require a commit or a diff. They retain the same SQLite/JSON formats. Opening a source comment navigates to its file and mapped selection; if that text is stale or the file is unavailable, it opens the preserved original text read-only instead of attaching feedback to different code.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
|
||||
+32
-34
@@ -641,7 +641,6 @@ pub struct DiffState {
|
||||
pub loading: bool,
|
||||
pub error: Option<String>,
|
||||
pub comparison: Option<Comparison>,
|
||||
pub local_review: crate::review::ReviewState,
|
||||
pub comparison_id: Option<u64>,
|
||||
pub reviewed_files: BTreeSet<usize>,
|
||||
pub(crate) snapshot_roots: [Option<PathBuf>; 2],
|
||||
@@ -708,7 +707,6 @@ impl DiffState {
|
||||
loading: true,
|
||||
error: None,
|
||||
comparison: None,
|
||||
local_review: Default::default(),
|
||||
comparison_id: None,
|
||||
reviewed_files: BTreeSet::new(),
|
||||
files: None,
|
||||
@@ -1988,7 +1986,10 @@ impl DiffState {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_reviewed(&mut self) -> Result<bool, &'static str> {
|
||||
pub(crate) fn toggle_reviewed(
|
||||
&mut self,
|
||||
review: &mut crate::review::ReviewState,
|
||||
) -> Result<bool, &'static str> {
|
||||
let comparison = self
|
||||
.comparison
|
||||
.as_ref()
|
||||
@@ -2030,10 +2031,10 @@ impl DiffState {
|
||||
.iter()
|
||||
.all(|revision| !revision.is_empty())
|
||||
{
|
||||
if self.local_review.document.root.as_os_str().is_empty() {
|
||||
self.local_review.document = crate::review::Review::new(comparison.root.clone());
|
||||
if review.document.root.as_os_str().is_empty() {
|
||||
review.document = crate::review::Review::new(comparison.root.clone());
|
||||
}
|
||||
let records = &mut self.local_review.document.reviewed_files;
|
||||
let records = &mut review.document.reviewed_files;
|
||||
records.retain(|record| {
|
||||
record.revisions
|
||||
!= *file
|
||||
@@ -2057,21 +2058,20 @@ impl DiffState {
|
||||
.then(|| file.after.join("\n")),
|
||||
});
|
||||
}
|
||||
self.local_review.dirty = true;
|
||||
review.dirty = true;
|
||||
}
|
||||
Ok(reviewed)
|
||||
}
|
||||
|
||||
pub(crate) fn restore_reviewed_files(&mut self) {
|
||||
pub(crate) fn restore_reviewed_files(&mut self, review: &crate::review::ReviewState) {
|
||||
self.reviewed_files.clear();
|
||||
let Some(comparison) = self.comparison.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if self.local_review.document.root != comparison.root {
|
||||
if review.document.root != comparison.root {
|
||||
return;
|
||||
}
|
||||
let records: BTreeMap<_, _> = self
|
||||
.local_review
|
||||
let records: BTreeMap<_, _> = review
|
||||
.document
|
||||
.reviewed_files
|
||||
.iter()
|
||||
@@ -2369,12 +2369,13 @@ impl DiffState {
|
||||
pub fn review_anchor_range(
|
||||
&self,
|
||||
anchor: &crate::review::Anchor,
|
||||
review: &crate::review::ReviewState,
|
||||
) -> Option<std::ops::Range<crate::plugin::TextPosition>> {
|
||||
use crate::review::Side;
|
||||
let Some(comparison) = &self.comparison else {
|
||||
return None;
|
||||
};
|
||||
if self.local_review.document.root != comparison.root {
|
||||
if review.document.root != comparison.root {
|
||||
return None;
|
||||
}
|
||||
comparison
|
||||
@@ -2481,7 +2482,12 @@ impl DiffState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_after(&mut self, lines: &[String], revision: u64) {
|
||||
pub fn refresh_after(
|
||||
&mut self,
|
||||
lines: &[String],
|
||||
revision: u64,
|
||||
review: &mut crate::review::ReviewState,
|
||||
) {
|
||||
if !self.live_after_available() {
|
||||
return;
|
||||
}
|
||||
@@ -2510,29 +2516,21 @@ impl DiffState {
|
||||
self.modified_after.insert(self.file);
|
||||
}
|
||||
let comparison = self.comparison.as_ref().unwrap();
|
||||
let record = (self.local_review.document.root == comparison.root)
|
||||
let record = (review.document.root == comparison.root)
|
||||
.then(|| {
|
||||
self.local_review
|
||||
.document
|
||||
.reviewed_files
|
||||
.iter()
|
||||
.position(|record| {
|
||||
record.revisions
|
||||
== *file
|
||||
.review_revisions
|
||||
.as_ref()
|
||||
.unwrap_or(&comparison.revisions)
|
||||
&& record.path == file.path
|
||||
&& record.old_path == file.old_path
|
||||
})
|
||||
review.document.reviewed_files.iter().position(|record| {
|
||||
record.revisions
|
||||
== *file
|
||||
.review_revisions
|
||||
.as_ref()
|
||||
.unwrap_or(&comparison.revisions)
|
||||
&& record.path == file.path
|
||||
&& record.old_path == file.old_path
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
let matches = record
|
||||
.and_then(|position| {
|
||||
self.local_review.document.reviewed_files[position]
|
||||
.after
|
||||
.as_ref()
|
||||
})
|
||||
.and_then(|position| review.document.reviewed_files[position].after.as_ref())
|
||||
.map(|text| text.split('\n').eq(lines.iter().map(String::as_str)));
|
||||
if matches == Some(true) {
|
||||
self.reviewed_files.insert(self.file);
|
||||
@@ -2540,8 +2538,8 @@ impl DiffState {
|
||||
if self.reviewed_files.contains(&self.file) && !matches.unwrap_or(unchanged) {
|
||||
self.reviewed_files.remove(&self.file);
|
||||
if let Some(position) = record {
|
||||
self.local_review.document.reviewed_files.remove(position);
|
||||
self.local_review.dirty = true;
|
||||
review.document.reviewed_files.remove(position);
|
||||
review.dirty = true;
|
||||
}
|
||||
}
|
||||
if was_live && changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2 {
|
||||
|
||||
+85
-59
@@ -990,96 +990,91 @@ core_actions![
|
||||
true,
|
||||
),
|
||||
(
|
||||
"core.diff.comment",
|
||||
Action::DiffComment,
|
||||
"core.comment",
|
||||
Action::Comment,
|
||||
&[Mode::Normal, Mode::Visual],
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.edit",
|
||||
"core.review.edit",
|
||||
Action::ReviewEdit,
|
||||
&[Mode::Normal],
|
||||
true,
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.delete",
|
||||
"core.review.delete",
|
||||
Action::ReviewDelete,
|
||||
&[Mode::Normal],
|
||||
true,
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.resolve",
|
||||
"core.review.resolve",
|
||||
Action::ReviewResolve,
|
||||
&[Mode::Normal],
|
||||
true,
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.unresolve",
|
||||
"core.review.unresolve",
|
||||
Action::ReviewUnresolve,
|
||||
&[Mode::Normal],
|
||||
true,
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.next",
|
||||
"core.review.next",
|
||||
Action::ReviewNext,
|
||||
&[Mode::Normal],
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.previous",
|
||||
"core.review.previous",
|
||||
Action::ReviewPrevious,
|
||||
&[Mode::Normal],
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.overview",
|
||||
"core.review.overview",
|
||||
Action::ReviewOverview,
|
||||
&[Mode::Normal],
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.copy",
|
||||
"core.review.copy",
|
||||
Action::ReviewCopy,
|
||||
&[Mode::Normal],
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.summary",
|
||||
"core.review.summary",
|
||||
Action::ReviewSummary,
|
||||
&[Mode::Normal],
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.verdict",
|
||||
"core.review.verdict",
|
||||
Action::ReviewVerdict,
|
||||
&[Mode::Normal],
|
||||
true,
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.save",
|
||||
"core.review.save",
|
||||
Action::ReviewSave,
|
||||
&[Mode::Normal],
|
||||
true,
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.open",
|
||||
"core.review.open",
|
||||
Action::ReviewOpen,
|
||||
&[Mode::Normal],
|
||||
true,
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.review.new",
|
||||
Action::ReviewNew,
|
||||
&[Mode::Normal],
|
||||
true
|
||||
),
|
||||
("core.review.new", Action::ReviewNew, &[Mode::Normal], true),
|
||||
(
|
||||
"core.word_wrap.toggle",
|
||||
Action::WordWrapToggle,
|
||||
@@ -1893,6 +1888,7 @@ struct Tab {
|
||||
pane_id: usize,
|
||||
pane_layout: PaneTree,
|
||||
review_pane: Option<usize>,
|
||||
local_review: crate::review::ReviewState,
|
||||
file_picker: Option<FilePicker>,
|
||||
diff: Option<DiffState>,
|
||||
parked_diff: Option<DiffState>,
|
||||
@@ -2200,6 +2196,7 @@ pub struct Editor {
|
||||
panes: BTreeMap<usize, Pane>,
|
||||
pane_layout: PaneTree,
|
||||
review_pane: Option<usize>,
|
||||
pub(crate) local_review: crate::review::ReviewState,
|
||||
tab_id: usize,
|
||||
next_tab_id: usize,
|
||||
tab_order: Vec<usize>,
|
||||
@@ -2313,6 +2310,7 @@ impl Editor {
|
||||
)]),
|
||||
pane_layout: PaneTree::new(1),
|
||||
review_pane: None,
|
||||
local_review: Default::default(),
|
||||
tab_id: 1,
|
||||
next_tab_id: 2,
|
||||
tab_order: vec![1],
|
||||
@@ -3663,6 +3661,7 @@ impl Editor {
|
||||
pane_id: std::mem::replace(&mut self.pane_id, next.pane_id),
|
||||
pane_layout: std::mem::replace(&mut self.pane_layout, next.pane_layout),
|
||||
review_pane: std::mem::replace(&mut self.review_pane, next.review_pane),
|
||||
local_review: std::mem::replace(&mut self.local_review, next.local_review),
|
||||
file_picker: std::mem::replace(&mut self.file_picker, next.file_picker),
|
||||
diff: std::mem::replace(&mut self.diff, next.diff),
|
||||
parked_diff: std::mem::replace(&mut self.parked_diff, next.parked_diff),
|
||||
@@ -3712,6 +3711,7 @@ impl Editor {
|
||||
pane_id,
|
||||
pane_layout: PaneTree::new(pane_id),
|
||||
review_pane: None,
|
||||
local_review: Default::default(),
|
||||
file_picker: None,
|
||||
diff: None,
|
||||
parked_diff: None,
|
||||
@@ -3814,11 +3814,6 @@ impl Editor {
|
||||
|
||||
// No input dispatch or document copies: source documents stay unique in active/parked.
|
||||
fn select_pane(&mut self, id: usize) {
|
||||
let id = if self.review_pane == Some(id) && self.workspace_diff().is_none() {
|
||||
self.panes[&id].origin.unwrap_or(id)
|
||||
} else {
|
||||
id
|
||||
};
|
||||
if id == self.pane_id {
|
||||
return;
|
||||
}
|
||||
@@ -4223,7 +4218,12 @@ impl Editor {
|
||||
return;
|
||||
}
|
||||
self.finish_pane_interaction();
|
||||
if self.diff_owner == Some(id) && !force && self.local_review_close_blocked() {
|
||||
let review_source =
|
||||
self.review_pane.and_then(|panel| self.panes[&panel].origin) == Some(id);
|
||||
if (self.diff_owner == Some(id) || review_source)
|
||||
&& !force
|
||||
&& self.local_review_close_blocked()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.diff_owner == Some(id) {
|
||||
@@ -4236,6 +4236,9 @@ impl Editor {
|
||||
}
|
||||
let next = self.pane_layout.close(id).expect("another pane exists");
|
||||
self.select_pane(next);
|
||||
if review_source && let Some(panel) = self.review_pane {
|
||||
self.panes.get_mut(&panel).unwrap().origin = Some(next);
|
||||
}
|
||||
self.panes.remove(&id);
|
||||
for buffer in buffers {
|
||||
self.forget_buffer(buffer);
|
||||
@@ -4627,11 +4630,17 @@ impl Editor {
|
||||
view.clamp(&self.active.lines);
|
||||
self.active.view = view;
|
||||
}
|
||||
if let Err(error) = self.initialize_local_review() {
|
||||
if self.review_directory.is_some()
|
||||
&& self.resolver().is_none()
|
||||
&& self
|
||||
.workspace_diff()
|
||||
.is_some_and(|diff| diff.phase == Phase::View)
|
||||
&& let Err(error) = self.initialize_local_review()
|
||||
{
|
||||
self.set_message(format!("Local review: {error}"));
|
||||
}
|
||||
if restore_reviewed && let Some(diff) = &mut self.diff {
|
||||
diff.restore_reviewed_files();
|
||||
diff.restore_reviewed_files(&self.local_review);
|
||||
}
|
||||
let reviewed = self.diff.as_mut().and_then(|diff| {
|
||||
(refresh
|
||||
@@ -5190,16 +5199,19 @@ impl Editor {
|
||||
|
||||
pub(crate) fn refresh_live_diff(&mut self) {
|
||||
self.refresh_resolver_comparison();
|
||||
for diff in self
|
||||
.diff
|
||||
.iter_mut()
|
||||
.chain(self.parked_diff.iter_mut())
|
||||
.chain(
|
||||
self.tabs
|
||||
.values_mut()
|
||||
.flat_map(|tab| tab.diff.iter_mut().chain(tab.parked_diff.iter_mut())),
|
||||
)
|
||||
{
|
||||
for (diff, parked, review) in std::iter::once((
|
||||
&mut self.diff,
|
||||
&mut self.parked_diff,
|
||||
&mut self.local_review,
|
||||
))
|
||||
.chain(
|
||||
self.tabs
|
||||
.values_mut()
|
||||
.map(|tab| (&mut tab.diff, &mut tab.parked_diff, &mut tab.local_review)),
|
||||
) {
|
||||
let Some(diff) = diff.as_mut().or(parked.as_mut()) else {
|
||||
continue;
|
||||
};
|
||||
let buffer = diff.live_buffer.and_then(|id| {
|
||||
if id == self.active.id {
|
||||
Some(&self.active)
|
||||
@@ -5211,7 +5223,7 @@ impl Editor {
|
||||
&& diff.live_after_available()
|
||||
&& diff.live_revision != Some(buffer.text_revision)
|
||||
{
|
||||
diff.refresh_after(&buffer.lines, buffer.text_revision);
|
||||
diff.refresh_after(&buffer.lines, buffer.text_revision, review);
|
||||
diff.follow_caret = true;
|
||||
}
|
||||
}
|
||||
@@ -5485,7 +5497,7 @@ impl Editor {
|
||||
| Action::DiffViewToggle
|
||||
| Action::DiffOrientationToggle
|
||||
| Action::DiffReviewToggle
|
||||
| Action::DiffComment
|
||||
| Action::Comment
|
||||
| Action::ReviewEdit
|
||||
| Action::ReviewDelete
|
||||
| Action::ReviewResolve
|
||||
@@ -8917,7 +8929,7 @@ impl Editor {
|
||||
| Action::DiffViewToggle
|
||||
| Action::DiffOrientationToggle
|
||||
| Action::DiffReviewToggle
|
||||
| Action::DiffComment
|
||||
| Action::Comment
|
||||
| Action::ReviewEdit
|
||||
| Action::ReviewDelete
|
||||
| Action::ReviewResolve
|
||||
@@ -9559,15 +9571,22 @@ impl Editor {
|
||||
.map(|buffer| buffer.id);
|
||||
if let Some(id) = loaded {
|
||||
self.switch_buffer(id);
|
||||
return Ok(());
|
||||
} else {
|
||||
let buffer = Buffer::open(
|
||||
self.next_buffer_id,
|
||||
Some(path),
|
||||
Some(identity),
|
||||
allow_missing,
|
||||
)?;
|
||||
self.add_buffer(buffer);
|
||||
}
|
||||
if self.review_directory.is_some()
|
||||
&& self.workspace_diff().is_none()
|
||||
&& self.resolver().is_none()
|
||||
&& let Err(error) = self.initialize_local_review()
|
||||
{
|
||||
self.set_message(format!("Local review: {error}"));
|
||||
}
|
||||
let buffer = Buffer::open(
|
||||
self.next_buffer_id,
|
||||
Some(path),
|
||||
Some(identity),
|
||||
allow_missing,
|
||||
)?;
|
||||
self.add_buffer(buffer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -9745,7 +9764,7 @@ impl Editor {
|
||||
|
||||
fn restart(&mut self, force: bool) {
|
||||
if !force && self.local_reviews_dirty() {
|
||||
self.set_message("Unsaved local review; use :diff.review.save <path>");
|
||||
self.set_message("Unsaved local review; use :review.save <path>");
|
||||
return;
|
||||
}
|
||||
if force || !self.any_dirty() {
|
||||
@@ -9876,7 +9895,7 @@ impl Editor {
|
||||
}
|
||||
if matches!(
|
||||
entry.action,
|
||||
Action::DiffComment
|
||||
Action::Comment
|
||||
| Action::ReviewEdit
|
||||
| Action::ReviewDelete
|
||||
| Action::ReviewResolve
|
||||
@@ -10102,15 +10121,15 @@ impl Editor {
|
||||
self.diff_return_buffer = Some(self.active.id);
|
||||
return None;
|
||||
}
|
||||
let Some(diff) = self
|
||||
.picker_diff_mut()
|
||||
.filter(|diff| diff.phase == Phase::View)
|
||||
else {
|
||||
self.set_message("No comparison is open");
|
||||
return None;
|
||||
};
|
||||
if entry.action == Action::DiffReviewToggle {
|
||||
match diff.toggle_reviewed() {
|
||||
let mut review = std::mem::take(&mut self.local_review);
|
||||
let result = self
|
||||
.picker_diff_mut()
|
||||
.filter(|diff| diff.phase == Phase::View)
|
||||
.ok_or("No comparison is open")
|
||||
.and_then(|diff| diff.toggle_reviewed(&mut review));
|
||||
self.local_review = review;
|
||||
match result {
|
||||
Ok(reviewed) => {
|
||||
if self.resolver().is_none()
|
||||
&& let Err(error) = self.autosave_local_review()
|
||||
@@ -10128,6 +10147,13 @@ impl Editor {
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let Some(diff) = self
|
||||
.picker_diff_mut()
|
||||
.filter(|diff| diff.phase == Phase::View)
|
||||
else {
|
||||
self.set_message("No comparison is open");
|
||||
return None;
|
||||
};
|
||||
if entry.action == Action::DiffViewToggle {
|
||||
diff.set_view_mode(match diff.view_mode {
|
||||
ViewMode::Split => ViewMode::Unified,
|
||||
|
||||
+296
-202
@@ -25,58 +25,81 @@ pub(super) struct ReviewBuffer {
|
||||
impl Editor {
|
||||
pub fn set_review_directory(&mut self, directory: PathBuf) {
|
||||
self.review_directory = Some(directory);
|
||||
if self.review_root().is_ok()
|
||||
&& let Err(error) = self.initialize_local_review()
|
||||
{
|
||||
self.set_message(format!("Local review: {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn initialize_local_review(&mut self) -> Result<(), String> {
|
||||
let Some(directory) = self.review_directory.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(diff) = self
|
||||
.workspace_diff()
|
||||
.filter(|diff| diff.phase == Phase::View)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if diff.local_review.initialized {
|
||||
let root = self.review_root()?;
|
||||
if self.local_review.document.root == root {
|
||||
self.local_review.initialized = true;
|
||||
return Ok(());
|
||||
}
|
||||
let root = diff
|
||||
.comparison
|
||||
.as_ref()
|
||||
.ok_or("Comparison is loading")?
|
||||
.root
|
||||
.clone();
|
||||
let directory = directory.clone();
|
||||
self.review_diff_mut().unwrap().local_review.initialized = true;
|
||||
self.review_diff_mut().unwrap().local_review.document = Review::new(root.clone());
|
||||
let store = crate::review::Store::open(&directory)?;
|
||||
let mut reviews = store.list(&root)?;
|
||||
if self.current_review_dirty() {
|
||||
return Err("Save the current review before changing repositories".into());
|
||||
}
|
||||
let reviews = match self.review_directory.as_deref() {
|
||||
Some(directory) => crate::review::Store::open_read_only(directory)?
|
||||
.map(|store| store.list(&root))
|
||||
.transpose()?
|
||||
.unwrap_or_default(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
self.detach_review_buffers();
|
||||
self.local_review = crate::review::ReviewState {
|
||||
document: Review::new(root),
|
||||
initialized: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut reviews = reviews;
|
||||
if reviews.len() == 1 {
|
||||
self.review_diff_mut().unwrap().local_review.document = reviews.pop().unwrap();
|
||||
self.local_review.document = reviews.pop().unwrap();
|
||||
} else if reviews.len() > 1 {
|
||||
self.show_review_selector(reviews)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn review_root(&self) -> Result<PathBuf, String> {
|
||||
if let Some(diff) = self.workspace_diff() {
|
||||
return diff
|
||||
.comparison
|
||||
.as_ref()
|
||||
.map(|comparison| {
|
||||
fs::canonicalize(&comparison.root).unwrap_or_else(|_| comparison.root.clone())
|
||||
})
|
||||
.ok_or_else(|| "Comparison is still loading".into());
|
||||
}
|
||||
if self.active.review_buffer.is_some()
|
||||
|| (self.active.path.is_none() && self.local_review.initialized)
|
||||
{
|
||||
return Ok(self.local_review.document.root.clone());
|
||||
}
|
||||
let path = self
|
||||
.active
|
||||
.identity
|
||||
.as_deref()
|
||||
.or(self.active.path.as_deref())
|
||||
.ok_or("Save the source file before commenting")?;
|
||||
crate::repository::discover_root(path.parent().ok_or("Source has no parent directory")?)
|
||||
}
|
||||
|
||||
fn open_review_selector(&mut self) -> Result<(), String> {
|
||||
let diff = self.workspace_diff().ok_or("No comparison is open")?;
|
||||
let root = self.review_root()?;
|
||||
let directory = self
|
||||
.review_directory
|
||||
.as_deref()
|
||||
.ok_or("Review storage is not configured")?;
|
||||
let root = &diff
|
||||
.comparison
|
||||
.as_ref()
|
||||
.ok_or("Comparison is loading")?
|
||||
.root;
|
||||
let reviews = crate::review::Store::open(directory)?.list(root)?;
|
||||
let reviews = crate::review::Store::open(directory)?.list(&root)?;
|
||||
self.show_review_selector(reviews)
|
||||
}
|
||||
|
||||
fn show_review_selector(&mut self, reviews: Vec<Review>) -> Result<(), String> {
|
||||
let mut text = String::from(
|
||||
"# Local reviews\n\nEnter opens the review under the caret.\n:diff.review.new starts a separate review.\n",
|
||||
"# Local reviews\n\nEnter opens the review under the caret.\n:review.new starts a separate review.\n",
|
||||
);
|
||||
for review in reviews {
|
||||
let id = review.storage_id.unwrap();
|
||||
@@ -95,14 +118,14 @@ impl Editor {
|
||||
self.open_review_buffer(
|
||||
ReviewText::Reviews,
|
||||
&text,
|
||||
"Local reviews · Enter opens · :diff.review.new".into(),
|
||||
"Local reviews · Enter opens · :review.new".into(),
|
||||
)?;
|
||||
self.place_navigation(TextPosition { row, column: 0 });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mark_review_saved(&mut self) {
|
||||
self.review_diff_mut().unwrap().local_review.dirty = false;
|
||||
self.local_review.dirty = false;
|
||||
for buffer in std::iter::once(&mut self.active).chain(self.parked.values_mut()) {
|
||||
if buffer
|
||||
.review_buffer
|
||||
@@ -120,21 +143,12 @@ impl Editor {
|
||||
let Some(directory) = self.review_directory.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
if !self
|
||||
.workspace_diff()
|
||||
.is_some_and(|diff| diff.local_review.dirty)
|
||||
{
|
||||
if !self.local_review.dirty {
|
||||
return Ok(());
|
||||
}
|
||||
let mut store = crate::review::Store::open(directory)?;
|
||||
store.save(
|
||||
&mut self
|
||||
.review_diff_mut()
|
||||
.ok_or("No comparison is open")?
|
||||
.local_review
|
||||
.document,
|
||||
)?;
|
||||
self.review_diff_mut().unwrap().local_review.dirty = false;
|
||||
store.save(&mut self.local_review.document)?;
|
||||
self.local_review.dirty = false;
|
||||
Ok(())
|
||||
}
|
||||
pub fn review_panel_title(&self) -> Option<&str> {
|
||||
@@ -145,8 +159,7 @@ impl Editor {
|
||||
}
|
||||
|
||||
pub fn review_panel(&self) -> Option<usize> {
|
||||
self.review_pane
|
||||
.filter(|pane| *pane == self.pane_id && self.workspace_diff().is_some())
|
||||
self.review_pane.filter(|pane| *pane == self.pane_id)
|
||||
}
|
||||
|
||||
pub(super) fn dismiss_review_panel(&mut self) -> bool {
|
||||
@@ -165,7 +178,7 @@ impl Editor {
|
||||
buffer.view.word_wrap = wrap;
|
||||
buffer.resolver_readonly = self.active.resolver_readonly;
|
||||
if binding.pending_comment.take().is_some() {
|
||||
self.review_diff_mut().unwrap().local_review.selected = None;
|
||||
self.local_review.selected = None;
|
||||
}
|
||||
buffer.review_buffer = Some(binding);
|
||||
self.active = buffer;
|
||||
@@ -175,25 +188,16 @@ impl Editor {
|
||||
true
|
||||
}
|
||||
|
||||
fn review_diff_mut(&mut self) -> Option<&mut DiffState> {
|
||||
self.diff.as_mut().or(self.parked_diff.as_mut())
|
||||
}
|
||||
|
||||
pub(super) fn local_reviews_dirty(&self) -> bool {
|
||||
self.workspace_diff()
|
||||
.is_some_and(|diff| diff.local_review.dirty)
|
||||
|| self
|
||||
.tabs
|
||||
.values()
|
||||
.any(|tab| tab.diff().is_some_and(|diff| diff.local_review.dirty))
|
||||
self.local_review.dirty
|
||||
|| self.tabs.values().any(|tab| tab.local_review.dirty)
|
||||
|| std::iter::once(&self.active)
|
||||
.chain(self.parked.values())
|
||||
.any(|buffer| buffer.review_buffer.is_some() && buffer.dirty())
|
||||
}
|
||||
|
||||
pub(super) fn current_review_dirty(&self) -> bool {
|
||||
self.workspace_diff()
|
||||
.is_some_and(|diff| diff.local_review.dirty)
|
||||
self.local_review.dirty
|
||||
|| std::iter::once(&self.active)
|
||||
.chain(self.parked.values())
|
||||
.any(|buffer| {
|
||||
@@ -213,7 +217,7 @@ impl Editor {
|
||||
return true;
|
||||
}
|
||||
if self.current_review_dirty() {
|
||||
self.set_message("Unsaved local review; use :diff.review.save <path> before closing");
|
||||
self.set_message("Unsaved local review; use :review.save <path> before closing");
|
||||
true
|
||||
} else {
|
||||
false
|
||||
@@ -258,13 +262,15 @@ impl Editor {
|
||||
};
|
||||
// Retire old drafts before comment IDs can be reused.
|
||||
self.detach_review_buffers();
|
||||
let state = &mut self.review_diff_mut().unwrap().local_review;
|
||||
let state = &mut self.local_review;
|
||||
state.document = document;
|
||||
state.path = path;
|
||||
state.selected = None;
|
||||
state.pending_navigation = None;
|
||||
state.dirty = dirty;
|
||||
self.review_diff_mut().unwrap().restore_reviewed_files();
|
||||
if let Some(diff) = self.diff.as_mut().or(self.parked_diff.as_mut()) {
|
||||
diff.restore_reviewed_files(&self.local_review);
|
||||
}
|
||||
// Persistence failure leaves the identity change applied and unsaved.
|
||||
self.autosave_local_review()?;
|
||||
self.open_review_overview()
|
||||
@@ -272,7 +278,7 @@ impl Editor {
|
||||
|
||||
fn delete_local_review_comment(&mut self, args: &str) -> Result<(), String> {
|
||||
let id = self.review_comment_id(args)?;
|
||||
let state = &mut self.review_diff_mut().unwrap().local_review;
|
||||
let state = &mut self.local_review;
|
||||
state.document.comments.retain(|comment| comment.id != id);
|
||||
state.selected = None;
|
||||
state.pending_navigation = None;
|
||||
@@ -308,14 +314,11 @@ impl Editor {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let Some(diff) = self.review_diff_mut() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let state = &mut self.local_review;
|
||||
let mut previous = Vec::new();
|
||||
for (kind, text, pending) in updates {
|
||||
if let Some(mut comment) = pending
|
||||
&& !diff
|
||||
.local_review
|
||||
&& !state
|
||||
.document
|
||||
.comments
|
||||
.iter()
|
||||
@@ -323,19 +326,18 @@ impl Editor {
|
||||
{
|
||||
previous.push((kind, None));
|
||||
comment.body = text;
|
||||
diff.local_review.document.comments.push(comment);
|
||||
diff.local_review.dirty = true;
|
||||
state.document.comments.push(comment);
|
||||
state.dirty = true;
|
||||
continue;
|
||||
}
|
||||
let destination = match kind {
|
||||
ReviewText::Comment(id) => diff
|
||||
.local_review
|
||||
ReviewText::Comment(id) => state
|
||||
.document
|
||||
.comments
|
||||
.iter_mut()
|
||||
.find(|comment| comment.id == id)
|
||||
.map(|comment| &mut comment.body),
|
||||
ReviewText::Summary => Some(&mut diff.local_review.document.summary),
|
||||
ReviewText::Summary => Some(&mut state.document.summary),
|
||||
ReviewText::Overview => None,
|
||||
ReviewText::Reviews => None,
|
||||
};
|
||||
@@ -344,22 +346,18 @@ impl Editor {
|
||||
{
|
||||
let old = std::mem::replace(destination, text);
|
||||
previous.push((kind, Some(old)));
|
||||
diff.local_review.dirty = true;
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
previous
|
||||
}
|
||||
|
||||
pub(super) fn save_local_review(&mut self, path: Option<&Path>) -> Result<(), String> {
|
||||
let dirty = self
|
||||
.review_diff_mut()
|
||||
.ok_or("No comparison is open")?
|
||||
.local_review
|
||||
.dirty;
|
||||
let dirty = self.local_review.dirty;
|
||||
let previous = self.sync_review_text();
|
||||
let result = self.write_local_review(path);
|
||||
if result.is_err() {
|
||||
let state = &mut self.review_diff_mut().unwrap().local_review;
|
||||
let state = &mut self.local_review;
|
||||
for (kind, text) in previous {
|
||||
match (kind, text) {
|
||||
(ReviewText::Comment(id), None) => {
|
||||
@@ -386,12 +384,8 @@ impl Editor {
|
||||
fn write_local_review(&mut self, path: Option<&Path>) -> Result<(), String> {
|
||||
if self.review_directory.is_some() {
|
||||
if let Some(path) = path {
|
||||
self.review_diff_mut()
|
||||
.ok_or("No comparison is open")?
|
||||
.local_review
|
||||
.document
|
||||
.save(path)?;
|
||||
self.review_diff_mut().unwrap().local_review.path = Some(path.to_path_buf());
|
||||
self.local_review.document.save(path)?;
|
||||
self.local_review.path = Some(path.to_path_buf());
|
||||
}
|
||||
self.autosave_local_review()?;
|
||||
self.mark_review_saved();
|
||||
@@ -402,14 +396,11 @@ impl Editor {
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
let state = &mut self
|
||||
.review_diff_mut()
|
||||
.ok_or("No comparison is open")?
|
||||
.local_review;
|
||||
let state = &mut self.local_review;
|
||||
let path = path
|
||||
.map(Path::to_path_buf)
|
||||
.or_else(|| state.path.clone())
|
||||
.ok_or("Use :diff.review.save <path> to choose a local JSON review file")?;
|
||||
.ok_or("Use :review.save <path> to choose a local JSON review file")?;
|
||||
state.document.save(&path)?;
|
||||
state.path = Some(path);
|
||||
self.mark_review_saved();
|
||||
@@ -423,19 +414,10 @@ impl Editor {
|
||||
args: &str,
|
||||
) -> Result<(), String> {
|
||||
if self.resolver().is_some() {
|
||||
return Err("Local reviews require an ordinary diff comparison".into());
|
||||
return Err("Local reviews are unavailable in conflict resolution workspaces".into());
|
||||
}
|
||||
self.initialize_local_review()?;
|
||||
let diff = self
|
||||
.workspace_diff()
|
||||
.filter(|diff| diff.phase == Phase::View)
|
||||
.ok_or("No comparison is open")?;
|
||||
let root = diff
|
||||
.comparison
|
||||
.as_ref()
|
||||
.ok_or("Comparison is still loading")?
|
||||
.root
|
||||
.clone();
|
||||
let root = self.review_root()?;
|
||||
if !matches!(
|
||||
action,
|
||||
Action::ReviewEdit
|
||||
@@ -449,16 +431,8 @@ impl Editor {
|
||||
{
|
||||
return Err("This review command takes no arguments".into());
|
||||
}
|
||||
if self
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.root
|
||||
.as_os_str()
|
||||
.is_empty()
|
||||
{
|
||||
self.review_diff_mut().unwrap().local_review.document = Review::new(root.clone());
|
||||
if self.local_review.document.root.as_os_str().is_empty() {
|
||||
self.local_review.document = Review::new(root.clone());
|
||||
}
|
||||
if !matches!(action, Action::ReviewSave | Action::ReviewCopy) {
|
||||
self.autosave_local_review()?;
|
||||
@@ -480,7 +454,7 @@ impl Editor {
|
||||
return self.replace_local_review(root, source);
|
||||
}
|
||||
match action {
|
||||
Action::DiffComment => self.add_review_comment()?,
|
||||
Action::Comment => self.add_review_comment()?,
|
||||
Action::ReviewNew => self.replace_local_review(root, ReviewSource::New)?,
|
||||
Action::ReviewEdit => {
|
||||
let id = self.review_comment_id(args)?;
|
||||
@@ -489,7 +463,7 @@ impl Editor {
|
||||
Action::ReviewResolve | Action::ReviewUnresolve => {
|
||||
let id = self.review_comment_id(args)?;
|
||||
let resolved = action == Action::ReviewResolve;
|
||||
let state = &mut self.review_diff_mut().unwrap().local_review;
|
||||
let state = &mut self.local_review;
|
||||
let comment = state
|
||||
.document
|
||||
.comments
|
||||
@@ -522,7 +496,7 @@ impl Editor {
|
||||
}
|
||||
Action::ReviewDelete => self.delete_local_review_comment(args)?,
|
||||
Action::ReviewNext | Action::ReviewPrevious => {
|
||||
let state = &self.workspace_diff().unwrap().local_review;
|
||||
let state = &self.local_review;
|
||||
let comments = &state.document.comments;
|
||||
if comments.is_empty() {
|
||||
return Err("No local review comments".into());
|
||||
@@ -540,7 +514,7 @@ impl Editor {
|
||||
}
|
||||
Action::ReviewOverview => self.open_review_overview()?,
|
||||
Action::ReviewCopy => {
|
||||
let review = &self.workspace_diff().unwrap().local_review.document;
|
||||
let review = &self.local_review.document;
|
||||
let count = review.comments.len();
|
||||
if count == 0 {
|
||||
return Err("No local review comments".into());
|
||||
@@ -554,13 +528,7 @@ impl Editor {
|
||||
self.set_message(format!("Copied {count} review comments"));
|
||||
}
|
||||
Action::ReviewSummary => {
|
||||
let text = self
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.summary
|
||||
.clone();
|
||||
let text = self.local_review.document.summary.clone();
|
||||
self.open_review_buffer(
|
||||
ReviewText::Summary,
|
||||
&text,
|
||||
@@ -574,11 +542,11 @@ impl Editor {
|
||||
"none" => None,
|
||||
_ => {
|
||||
return Err(
|
||||
"Usage: diff.review.verdict <looks_good|changes_requested|none>".into(),
|
||||
"Usage: review.verdict <looks_good|changes_requested|none>".into()
|
||||
);
|
||||
}
|
||||
};
|
||||
let state = &mut self.review_diff_mut().unwrap().local_review;
|
||||
let state = &mut self.local_review;
|
||||
if state.document.verdict != verdict {
|
||||
state.document.verdict = verdict;
|
||||
state.dirty = true;
|
||||
@@ -595,10 +563,7 @@ impl Editor {
|
||||
}
|
||||
|
||||
fn review_comment_id(&self, args: &str) -> Result<u64, String> {
|
||||
let state = &self
|
||||
.workspace_diff()
|
||||
.ok_or("No comparison is open")?
|
||||
.local_review;
|
||||
let state = &self.local_review;
|
||||
let under_cursor = self
|
||||
.active
|
||||
.review_buffer
|
||||
@@ -633,16 +598,65 @@ impl Editor {
|
||||
.ok_or_else(|| format!("No local comment #{id}"))
|
||||
}
|
||||
|
||||
fn add_review_comment(&mut self) -> Result<(), String> {
|
||||
let owner = self.diff_owner.ok_or("No comparison is open")?;
|
||||
if self.pane_id != owner {
|
||||
self.select_pane(owner);
|
||||
pub(crate) fn source_review_anchor_range(
|
||||
&self,
|
||||
anchor: &Anchor,
|
||||
) -> Option<std::ops::Range<TextPosition>> {
|
||||
if self.active.review_buffer.is_some() || !anchor.revision.starts_with("working:") {
|
||||
return None;
|
||||
}
|
||||
let path = self
|
||||
.active
|
||||
.identity
|
||||
.as_ref()
|
||||
.or(self.active.path.as_ref())?;
|
||||
let relative = path.strip_prefix(&self.local_review.document.root).ok()?;
|
||||
anchor.mapped_range(
|
||||
&anchor.revision,
|
||||
relative,
|
||||
Side::After,
|
||||
&self.active.lines,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
fn review_anchor_range(&self, anchor: &Anchor) -> Option<std::ops::Range<TextPosition>> {
|
||||
if let Some(diff) = self.workspace_diff() {
|
||||
return diff.review_anchor_range(anchor, &self.local_review);
|
||||
}
|
||||
let path = self.local_review.document.root.join(&anchor.path);
|
||||
let buffer = std::iter::once(&self.active)
|
||||
.chain(self.parked.values())
|
||||
.find(|buffer| {
|
||||
buffer.identity.as_ref() == Some(&path) && buffer.review_buffer.is_none()
|
||||
})?;
|
||||
if !anchor.revision.starts_with("working:") {
|
||||
return None;
|
||||
}
|
||||
anchor.mapped_range(
|
||||
&anchor.revision,
|
||||
&anchor.path,
|
||||
Side::After,
|
||||
&buffer.lines,
|
||||
false,
|
||||
)
|
||||
}
|
||||
fn add_review_comment(&mut self) -> Result<(), String> {
|
||||
if let Some(owner) = self.diff_owner {
|
||||
if self.pane_id != owner {
|
||||
self.select_pane(owner);
|
||||
}
|
||||
self.bind_diff_buffer();
|
||||
if self
|
||||
.diff_buffer_focus()
|
||||
.filter(|_| self.diff_interacting())
|
||||
.is_none()
|
||||
{
|
||||
return Err("Focus a diff text pane to add a comment".into());
|
||||
}
|
||||
} else if self.active.review_buffer.is_some() || self.active.resolver_readonly {
|
||||
return Err("Focus a source text pane to add a comment".into());
|
||||
}
|
||||
self.bind_diff_buffer();
|
||||
let focus = self
|
||||
.diff_buffer_focus()
|
||||
.filter(|_| self.diff_interacting())
|
||||
.ok_or("Focus a diff text pane to add a comment")?;
|
||||
if self.selection_count() != 1 {
|
||||
return Err("Use one caret or one contiguous selection for a review comment".into());
|
||||
}
|
||||
@@ -663,22 +677,45 @@ impl Editor {
|
||||
},
|
||||
}
|
||||
});
|
||||
let diff = self.diff.as_ref().unwrap();
|
||||
let comparison = diff.comparison.as_ref().unwrap();
|
||||
let file = &comparison.files[diff.file];
|
||||
let side = if focus == Focus::Before {
|
||||
Side::Before
|
||||
let content = self.active.lines.join("\n");
|
||||
let (revision, path, side) = if let Some(diff) = self.diff.as_ref() {
|
||||
let comparison = diff.comparison.as_ref().ok_or("Comparison is loading")?;
|
||||
let file = &comparison.files[diff.file];
|
||||
let side = if self.diff_buffer_focus() == Some(Focus::Before) {
|
||||
Side::Before
|
||||
} else {
|
||||
Side::After
|
||||
};
|
||||
(
|
||||
comparison.revisions[side.index()].clone(),
|
||||
if side == Side::Before {
|
||||
file.old_path.as_ref().unwrap_or(&file.path)
|
||||
} else {
|
||||
&file.path
|
||||
}
|
||||
.clone(),
|
||||
side,
|
||||
)
|
||||
} else {
|
||||
Side::After
|
||||
let root = &self.local_review.document.root;
|
||||
let path = self
|
||||
.active
|
||||
.identity
|
||||
.as_ref()
|
||||
.or(self.active.path.as_ref())
|
||||
.ok_or("Save the source file before commenting")?
|
||||
.strip_prefix(root)
|
||||
.map_err(|_| "Source is outside the review repository")?
|
||||
.to_path_buf();
|
||||
(
|
||||
crate::repository::source_snapshot_revision(root, &content)?,
|
||||
path,
|
||||
Side::After,
|
||||
)
|
||||
};
|
||||
let anchor = Anchor {
|
||||
revision: comparison.revisions[side.index()].clone(),
|
||||
path: if side == Side::Before {
|
||||
file.old_path.as_ref().unwrap_or(&file.path)
|
||||
} else {
|
||||
&file.path
|
||||
}
|
||||
.clone(),
|
||||
revision,
|
||||
path,
|
||||
side,
|
||||
start: TextPosition {
|
||||
row: range.start.row,
|
||||
@@ -688,9 +725,9 @@ impl Editor {
|
||||
row: range.end.row,
|
||||
column: range.end.column,
|
||||
},
|
||||
content: self.active.lines.join("\n"),
|
||||
content,
|
||||
};
|
||||
let id = diff
|
||||
let id = self
|
||||
.local_review
|
||||
.document
|
||||
.comments
|
||||
@@ -727,10 +764,10 @@ impl Editor {
|
||||
}
|
||||
|
||||
fn open_review_comment(&mut self, id: u64, draft: Option<&Comment>) -> Result<(), String> {
|
||||
let diff = self.workspace_diff().unwrap();
|
||||
let state = &self.local_review;
|
||||
let comment = draft
|
||||
.or_else(|| {
|
||||
diff.local_review
|
||||
state
|
||||
.document
|
||||
.comments
|
||||
.iter()
|
||||
@@ -744,7 +781,7 @@ impl Editor {
|
||||
} else {
|
||||
"unresolved"
|
||||
};
|
||||
let mapped = diff.review_anchor_range(anchor);
|
||||
let mapped = self.review_anchor_range(anchor);
|
||||
let current = mapped.is_some();
|
||||
let location = if current {
|
||||
"current"
|
||||
@@ -761,31 +798,80 @@ impl Editor {
|
||||
range.end.column + 1,
|
||||
anchor.side
|
||||
);
|
||||
let target = diff.comparison.as_ref().and_then(|comparison| {
|
||||
comparison.files.iter().position(|file| {
|
||||
let path = if anchor.side == Side::Before {
|
||||
file.old_path.as_ref().unwrap_or(&file.path)
|
||||
let anchor = anchor.clone();
|
||||
let mut stale = !current;
|
||||
self.local_review.selected = Some(id);
|
||||
if let Some(owner) = self.diff_owner {
|
||||
let target = self
|
||||
.workspace_diff()
|
||||
.and_then(|diff| diff.comparison.as_ref())
|
||||
.and_then(|comparison| {
|
||||
comparison.files.iter().position(|file| {
|
||||
if anchor.side == Side::Before {
|
||||
file.old_path.as_ref().unwrap_or(&file.path) == &anchor.path
|
||||
} else {
|
||||
file.path == anchor.path
|
||||
}
|
||||
})
|
||||
});
|
||||
stale = !current
|
||||
&& target.is_none_or(|index| {
|
||||
self.workspace_diff()
|
||||
.unwrap()
|
||||
.comparison
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.files[index]
|
||||
.loaded
|
||||
});
|
||||
self.select_pane(owner);
|
||||
if let Some(index) = target {
|
||||
self.diff.as_mut().unwrap().select_file_index(index);
|
||||
self.local_review.pending_navigation = Some(id);
|
||||
}
|
||||
self.finish_review_navigation();
|
||||
if draft.is_some() {
|
||||
self.place_navigation(anchor.start);
|
||||
}
|
||||
} else {
|
||||
if let Some(panel) = self.review_panel() {
|
||||
self.select_pane(self.panes[&panel].origin.unwrap());
|
||||
}
|
||||
let path = self.local_review.document.root.join(&anchor.path);
|
||||
if draft.is_none() {
|
||||
let opened = self.edit_buffer(path.clone(), false).is_ok();
|
||||
let mapped = opened.then(|| self.review_anchor_range(&anchor)).flatten();
|
||||
stale = mapped.is_none();
|
||||
if let Some(range) = mapped {
|
||||
self.place_navigation(range.start);
|
||||
} else {
|
||||
&file.path
|
||||
};
|
||||
path == &anchor.path
|
||||
})
|
||||
});
|
||||
let stale = !current
|
||||
&& target.is_none_or(|index| diff.comparison.as_ref().unwrap().files[index].loaded);
|
||||
let start = anchor.start;
|
||||
let owner = self.diff_owner.unwrap();
|
||||
self.select_pane(owner);
|
||||
let diff = self.diff.as_mut().unwrap();
|
||||
diff.local_review.selected = Some(id);
|
||||
if let Some(index) = target {
|
||||
diff.select_file_index(index);
|
||||
diff.local_review.pending_navigation = Some(id);
|
||||
}
|
||||
self.finish_review_navigation();
|
||||
if draft.is_some() {
|
||||
self.place_navigation(start);
|
||||
let mut snapshot = Buffer::from_text(
|
||||
self.next_buffer_id,
|
||||
Some(path),
|
||||
None,
|
||||
&anchor.content,
|
||||
None,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
snapshot.resolver_readonly = true;
|
||||
self.add_buffer(snapshot);
|
||||
self.place_navigation(anchor.start);
|
||||
}
|
||||
}
|
||||
}
|
||||
let title = title.replacen(
|
||||
if stale {
|
||||
" · current · "
|
||||
} else {
|
||||
" · original snapshot · "
|
||||
},
|
||||
if stale {
|
||||
" · original snapshot · "
|
||||
} else {
|
||||
" · current · "
|
||||
},
|
||||
1,
|
||||
);
|
||||
self.open_review_buffer(ReviewText::Comment(id), &text, title)?;
|
||||
if stale {
|
||||
self.set_message("Comment is stale; original snapshot anchor preserved");
|
||||
@@ -794,18 +880,18 @@ impl Editor {
|
||||
}
|
||||
|
||||
pub(super) fn finish_review_navigation(&mut self) {
|
||||
let state = &mut self.local_review;
|
||||
let Some(diff) = self.diff.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(id) = diff.local_review.pending_navigation else {
|
||||
let Some(id) = state.pending_navigation else {
|
||||
return;
|
||||
};
|
||||
if !diff.selected_file_loaded() {
|
||||
return;
|
||||
}
|
||||
diff.local_review.pending_navigation = None;
|
||||
let Some(comment) = diff
|
||||
.local_review
|
||||
state.pending_navigation = None;
|
||||
let Some(comment) = state
|
||||
.document
|
||||
.comments
|
||||
.iter()
|
||||
@@ -827,7 +913,7 @@ impl Editor {
|
||||
if selected_path != Some(&comment.anchor.path) {
|
||||
return;
|
||||
}
|
||||
let Some(range) = diff.review_anchor_range(&comment.anchor) else {
|
||||
let Some(range) = diff.review_anchor_range(&comment.anchor, state) else {
|
||||
self.set_message("Comment is stale; original snapshot anchor preserved");
|
||||
return;
|
||||
};
|
||||
@@ -844,8 +930,7 @@ impl Editor {
|
||||
}
|
||||
|
||||
fn open_review_overview(&mut self) -> Result<(), String> {
|
||||
let diff = self.workspace_diff().unwrap();
|
||||
let state = &diff.local_review;
|
||||
let state = &self.local_review;
|
||||
let verdict = match state.document.verdict {
|
||||
Some(Verdict::LooksGood) => "Looks good",
|
||||
Some(Verdict::ChangesRequested) => "Changes requested",
|
||||
@@ -868,25 +953,29 @@ impl Editor {
|
||||
.map_or_else(|| "not saved".into(), |path| path.display().to_string())
|
||||
};
|
||||
let mut text = format!(
|
||||
"# Local review\n\nVerdict: {verdict}\nStorage: {storage}\n\n## Summary\n{}\n\nEnter edits the comment under the caret.\n:diff.review.summary · :diff.review.verdict · :diff.review.save\n",
|
||||
"# Local review\n\nVerdict: {verdict}\nStorage: {storage}\n\n## Summary\n{}\n\nEnter edits the comment under the caret.\n:review.summary · :review.verdict · :review.save\n",
|
||||
state.document.summary
|
||||
);
|
||||
for comment in &state.document.comments {
|
||||
let anchor = &comment.anchor;
|
||||
let mapped = diff.review_anchor_range(anchor);
|
||||
let mapped = self.review_anchor_range(anchor);
|
||||
let range = mapped.clone().unwrap_or(anchor.start..anchor.end);
|
||||
let status = if mapped.is_some() {
|
||||
"current"
|
||||
} else if diff.comparison.as_ref().is_some_and(|comparison| {
|
||||
comparison.files.iter().any(|file| {
|
||||
!file.loaded
|
||||
&& if anchor.side == Side::Before {
|
||||
file.old_path.as_ref().unwrap_or(&file.path) == &anchor.path
|
||||
} else {
|
||||
file.path == anchor.path
|
||||
}
|
||||
} else if self
|
||||
.workspace_diff()
|
||||
.and_then(|diff| diff.comparison.as_ref())
|
||||
.is_some_and(|comparison| {
|
||||
comparison.files.iter().any(|file| {
|
||||
!file.loaded
|
||||
&& if anchor.side == Side::Before {
|
||||
file.old_path.as_ref().unwrap_or(&file.path) == &anchor.path
|
||||
} else {
|
||||
file.path == anchor.path
|
||||
}
|
||||
})
|
||||
})
|
||||
}) {
|
||||
{
|
||||
"not checked (file not loaded)"
|
||||
} else {
|
||||
"stale"
|
||||
@@ -915,7 +1004,7 @@ impl Editor {
|
||||
self.open_review_buffer(
|
||||
ReviewText::Overview,
|
||||
&text,
|
||||
"Local review overview · Enter edits · :diff.review.next".into(),
|
||||
"Local review overview · Enter edits · :review.next".into(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -927,6 +1016,11 @@ impl Editor {
|
||||
) -> Result<(), String> {
|
||||
self.prepare_command();
|
||||
self.finish_command_mode();
|
||||
let source = if self.review_panel().is_some() {
|
||||
self.panes[&self.pane_id].origin.unwrap()
|
||||
} else {
|
||||
self.pane_id
|
||||
};
|
||||
let existing = std::iter::once(&self.active)
|
||||
.chain(self.parked.values())
|
||||
.find(|buffer| {
|
||||
@@ -945,14 +1039,14 @@ impl Editor {
|
||||
id,
|
||||
Pane {
|
||||
buffer_id: self.active.id,
|
||||
origin: self.diff_owner,
|
||||
origin: Some(source),
|
||||
views: BTreeMap::from([(self.active.id, self.active.view.inherit())]),
|
||||
},
|
||||
);
|
||||
self.review_pane = Some(id);
|
||||
id
|
||||
};
|
||||
self.panes.get_mut(&panel).unwrap().origin = self.diff_owner;
|
||||
self.panes.get_mut(&panel).unwrap().origin = Some(source);
|
||||
self.select_pane(panel);
|
||||
if let Some(id) = existing {
|
||||
self.switch_buffer(id);
|
||||
@@ -978,9 +1072,9 @@ impl Editor {
|
||||
self.add_buffer(buffer);
|
||||
}
|
||||
self.set_message(if self.review_directory.is_some() {
|
||||
"Local review · :w saves · Escape discards unsaved text · :diff.review.save path exports JSON"
|
||||
"Local review · :w saves · Escape discards unsaved text · :review.save path exports JSON"
|
||||
} else {
|
||||
"Local review · :w saves JSON; first choose :diff.review.save <path>"
|
||||
"Local review · :w saves JSON; first choose :review.save <path>"
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+6
-5
@@ -96,13 +96,14 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
|
||||
Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly
|
||||
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
||||
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
|
||||
Local reviews: :diff.comment anchors exact Visual text, Visual Line rows, or the current line
|
||||
Local reviews: :comment anchors exact Visual text, Visual Line rows, or the current line
|
||||
Works in ordinary repository source buffers and diff text panes
|
||||
Selected comments highlight their precise text
|
||||
: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.copy copies all saved comments in CLI format using the configured clipboard
|
||||
:review.edit/delete [ID], :review.next/previous, :review.overview
|
||||
:review.summary; :review.verdict looks_good|changes_requested|none
|
||||
: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
|
||||
:diff.review.save PATH exports JSON; :diff.review.open [ID|PATH] selects/imports; :diff.review.new
|
||||
:review.save PATH exports JSON; :review.open [ID|PATH] selects/imports; :review.new
|
||||
Normal Esc or :pane.close discards unsaved text and dismisses the floating editor
|
||||
Snapshot anchors stay immutable; stale comments lose gutter markers; no publishing
|
||||
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/Drafts/All tabs, type to search
|
||||
|
||||
@@ -1093,6 +1093,24 @@ pub fn discover_root(path: &Path) -> Result<PathBuf, String> {
|
||||
discover_root_with_cancellation(path, &AtomicBool::new(false))
|
||||
}
|
||||
|
||||
/// Identify the exact source bytes without writing objects or snapshotting the checkout.
|
||||
pub(crate) fn source_snapshot_revision(root: &Path, content: &str) -> Result<String, String> {
|
||||
let mut command = if root.join(".jj").exists() {
|
||||
git_command(&store_for_jj(root)?)
|
||||
} else {
|
||||
let mut command = Command::new("git");
|
||||
command.arg("-C").arg(root);
|
||||
command
|
||||
};
|
||||
command.args(["hash-object", "--no-filters", "--stdin"]);
|
||||
let id = text(checked(run(
|
||||
command,
|
||||
content.as_bytes().to_vec(),
|
||||
&AtomicBool::new(false),
|
||||
)?)?)?;
|
||||
Ok(format!("working:{}", id.trim()))
|
||||
}
|
||||
|
||||
fn discover_root_with_cancellation(path: &Path, cancelled: &AtomicBool) -> Result<PathBuf, String> {
|
||||
let path = fs::canonicalize(path).map_err(|error| error.to_string())?;
|
||||
let root = path
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ pub enum Verdict {
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Anchor {
|
||||
/// Frozen comparison context; `content` additionally captures any live buffer edits.
|
||||
/// Frozen repository context; source comments use the exact buffer's Git blob identity.
|
||||
pub revision: String,
|
||||
pub path: PathBuf,
|
||||
pub side: Side,
|
||||
|
||||
+80
-9
@@ -807,6 +807,7 @@ impl BufferViewports {
|
||||
editor.file_navigation_hint(),
|
||||
&mut self.panels,
|
||||
Some(editor),
|
||||
Some(&editor.local_review),
|
||||
);
|
||||
} else {
|
||||
draw_overlays(
|
||||
@@ -1009,6 +1010,7 @@ impl BufferViewports {
|
||||
view.whitespace_scope(),
|
||||
view.resolver(),
|
||||
true,
|
||||
None,
|
||||
);
|
||||
if view.render_pane_focused()
|
||||
&& !view.file_picker().is_some_and(|picker| picker.focused)
|
||||
@@ -1823,7 +1825,14 @@ fn chrome_items<'a>(
|
||||
tab: None,
|
||||
accent: !matches!(
|
||||
name.as_str(),
|
||||
"path" | "language" | "diagnostics" | "selections" | "count" | "pending" | "buffer" | "diff"
|
||||
"path"
|
||||
| "language"
|
||||
| "diagnostics"
|
||||
| "selections"
|
||||
| "count"
|
||||
| "pending"
|
||||
| "buffer"
|
||||
| "diff"
|
||||
),
|
||||
bold: false,
|
||||
first: true,
|
||||
@@ -2236,6 +2245,7 @@ fn draw_document(
|
||||
editor.file_navigation_hint(),
|
||||
panels,
|
||||
Some(editor),
|
||||
Some(&editor.local_review),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -2275,6 +2285,17 @@ fn draw_document(
|
||||
let mut document_caret = None;
|
||||
let carets = editor.carets();
|
||||
let selections = editor.selections();
|
||||
let review_comments: Vec<_> = editor
|
||||
.local_review
|
||||
.document
|
||||
.comments
|
||||
.iter()
|
||||
.filter_map(|comment| {
|
||||
editor
|
||||
.source_review_anchor_range(&comment.anchor)
|
||||
.map(|range| (comment, range))
|
||||
})
|
||||
.collect();
|
||||
let wrapped = editor.word_wrap();
|
||||
let presentation = (editor.buffer_id(), wrapped, width, height);
|
||||
let identity = (editor.buffer_id(), editor.text_revision(), cursor);
|
||||
@@ -2576,6 +2597,25 @@ fn draw_document(
|
||||
})
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(numbers), gutter);
|
||||
for (screen, &(row, continuation, _)) in document.visible.iter().enumerate() {
|
||||
if continuation != 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some((comment, _)) = review_comments
|
||||
.iter()
|
||||
.filter(|(_, range)| {
|
||||
let end = range.end.row
|
||||
- usize::from(range.end.column == 0 && range.end.row > range.start.row);
|
||||
(range.start.row..=end).contains(&row)
|
||||
})
|
||||
.max_by_key(|(comment, _)| editor.local_review.selected == Some(comment.id))
|
||||
{
|
||||
let selected = editor.local_review.selected == Some(comment.id);
|
||||
frame.buffer_mut()[(gutter.right() - 1, gutter.y + screen as u16)]
|
||||
.set_symbol(if selected { "●" } else { "○" })
|
||||
.set_fg(Color::Magenta);
|
||||
}
|
||||
}
|
||||
}
|
||||
let lines: Vec<Line> = document
|
||||
.visible
|
||||
@@ -2627,6 +2667,33 @@ fn draw_document(
|
||||
if editor.highlight_trailing_whitespace() {
|
||||
paint_trailing_whitespace(frame, line, area, segment.start);
|
||||
}
|
||||
if let Some((_, range)) = review_comments
|
||||
.iter()
|
||||
.find(|(comment, _)| editor.local_review.selected == Some(comment.id))
|
||||
{
|
||||
let end_row = range.end.row
|
||||
- usize::from(range.end.column == 0 && range.end.row > range.start.row);
|
||||
if (range.start.row..=end_row).contains(&row) {
|
||||
let start = if row == range.start.row {
|
||||
range.start.column
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let end = if row == range.end.row {
|
||||
range.end.column
|
||||
} else {
|
||||
usize::MAX
|
||||
};
|
||||
paint_grapheme_range(
|
||||
frame,
|
||||
area,
|
||||
segment.start,
|
||||
line,
|
||||
start..end,
|
||||
Style::default().bg(Color::Magenta),
|
||||
);
|
||||
}
|
||||
}
|
||||
paint_editor_line(
|
||||
frame,
|
||||
editor,
|
||||
@@ -3337,6 +3404,7 @@ fn draw_diff_popups(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_diff(
|
||||
frame: &mut Frame,
|
||||
diff: &DiffState,
|
||||
@@ -3345,6 +3413,7 @@ fn draw_diff(
|
||||
file_navigation_hint: &str,
|
||||
panels: &mut WheelPanels,
|
||||
editor: Option<&Editor>,
|
||||
review: Option<&crate::review::ReviewState>,
|
||||
) {
|
||||
if let Some(editor) = editor {
|
||||
panels.picker_scroll.distance = editor.scroll_distance();
|
||||
@@ -3814,7 +3883,7 @@ fn draw_diff(
|
||||
comparison.map(|comparison| (comparison.files.as_slice(), &diff.reviewed_files)),
|
||||
);
|
||||
}
|
||||
let document_caret = panels.draw_documents(frame, diff, panes, navigator, editor);
|
||||
let document_caret = panels.draw_documents(frame, diff, panes, navigator, editor, review);
|
||||
if let Some(editor) = editor {
|
||||
draw_diff_popups(frame, editor, area, document_caret, panels);
|
||||
}
|
||||
@@ -3839,6 +3908,7 @@ impl WheelPanels {
|
||||
panes: Rect,
|
||||
navigator: Option<Rect>,
|
||||
editor: Option<&Editor>,
|
||||
review: Option<&crate::review::ReviewState>,
|
||||
) -> Option<(Rect, Position)> {
|
||||
self.cached_diff_error(Some(diff));
|
||||
let selected = diff
|
||||
@@ -4087,6 +4157,7 @@ impl WheelPanels {
|
||||
editor.map_or(WhitespaceScope::All, Editor::whitespace_scope),
|
||||
resolver,
|
||||
false,
|
||||
review,
|
||||
);
|
||||
text_widths[0] = text_area.width;
|
||||
if let Some(editor) =
|
||||
@@ -4127,6 +4198,7 @@ impl WheelPanels {
|
||||
editor.map_or(WhitespaceScope::All, Editor::whitespace_scope),
|
||||
resolver,
|
||||
false,
|
||||
review,
|
||||
);
|
||||
text_widths[1] = text_area.width;
|
||||
if let Some(editor) =
|
||||
@@ -4464,6 +4536,7 @@ fn draw_diff_pane(
|
||||
whitespace_scope: WhitespaceScope,
|
||||
resolver: Option<&ResolverWorkspace>,
|
||||
content_only: bool,
|
||||
review: Option<&crate::review::ReviewState>,
|
||||
) -> Rect {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return area;
|
||||
@@ -4487,11 +4560,9 @@ fn draw_diff_pane(
|
||||
*left = diff.bound_horizontal(focus, *left, width);
|
||||
}
|
||||
let left = *left;
|
||||
let review_comments = diff
|
||||
.local_review
|
||||
.document
|
||||
.comments
|
||||
.iter()
|
||||
let review_comments = review
|
||||
.into_iter()
|
||||
.flat_map(|review| review.document.comments.iter())
|
||||
.filter_map(|comment| {
|
||||
let before = comment.anchor.side == crate::review::Side::Before;
|
||||
let path = if before {
|
||||
@@ -4502,7 +4573,7 @@ fn draw_diff_pane(
|
||||
if !(unified || before == (focus == Focus::Before)) || comment.anchor.path != *path {
|
||||
return None;
|
||||
}
|
||||
diff.review_anchor_range(&comment.anchor)
|
||||
diff.review_anchor_range(&comment.anchor, review.unwrap())
|
||||
.map(|range| (comment, range))
|
||||
});
|
||||
paint_diff_content(
|
||||
@@ -4518,7 +4589,7 @@ fn draw_diff_pane(
|
||||
whitespace_characters,
|
||||
whitespace_scope,
|
||||
review_comments,
|
||||
diff.local_review.selected,
|
||||
review.and_then(|review| review.selected),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+107
-78
@@ -2460,6 +2460,7 @@ fn column_view_suppresses_whole_file_emphasis_without_losing_change_markers() {
|
||||
|
||||
#[test]
|
||||
fn working_refresh_preserves_noops_errors_and_view_state_but_invalidates_old_identity() {
|
||||
let mut review = crate::review::ReviewState::default();
|
||||
let mut state = live_state(vec!["old".into()], vec!["new".into()], false);
|
||||
state.loading = false;
|
||||
state.pending = None;
|
||||
@@ -2554,7 +2555,7 @@ fn working_refresh_preserves_noops_errors_and_view_state_but_invalidates_old_ide
|
||||
.unwrap()
|
||||
.editable_paths
|
||||
.insert("file".into());
|
||||
state.refresh_after(&["dirty".into()], 1);
|
||||
state.refresh_after(&["dirty".into()], 1, &mut review);
|
||||
assert_eq!(state.comparison.as_ref().unwrap().files[1].after, ["dirty"]);
|
||||
state.queue_checkout_refresh(false);
|
||||
state.take_request();
|
||||
@@ -2575,6 +2576,7 @@ fn working_refresh_preserves_noops_errors_and_view_state_but_invalidates_old_ide
|
||||
|
||||
#[test]
|
||||
fn reviewed_files_use_highlighted_catalog_indices_not_sorted_or_filtered_rows() {
|
||||
let mut review = crate::review::ReviewState::default();
|
||||
let mut state = DiffState::new();
|
||||
let mut changes = comparison();
|
||||
changes.files[0].path = "nested/z.txt".into();
|
||||
@@ -2591,9 +2593,9 @@ fn reviewed_files_use_highlighted_catalog_indices_not_sorted_or_filtered_rows()
|
||||
}
|
||||
files.reveal_path(Path::new("nested/a.txt"));
|
||||
assert_eq!(state.file, 0);
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
assert_eq!(state.toggle_reviewed(&mut review), Ok(true));
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([1]));
|
||||
assert_eq!(state.toggle_reviewed(), Ok(false));
|
||||
assert_eq!(state.toggle_reviewed(&mut review), Ok(false));
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
}
|
||||
let files = state.files.as_mut().unwrap();
|
||||
@@ -2602,7 +2604,7 @@ fn reviewed_files_use_highlighted_catalog_indices_not_sorted_or_filtered_rows()
|
||||
let selected = files.entries().position(|entry| entry.is_dir).unwrap();
|
||||
files.selected = selected;
|
||||
assert_eq!(
|
||||
state.toggle_reviewed(),
|
||||
state.toggle_reviewed(&mut review),
|
||||
Err("Select a changed file, not a directory")
|
||||
);
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
@@ -2611,32 +2613,42 @@ fn reviewed_files_use_highlighted_catalog_indices_not_sorted_or_filtered_rows()
|
||||
for ch in "missing".chars() {
|
||||
files.handle_filter_input(&Input::Char(ch));
|
||||
}
|
||||
assert_eq!(state.toggle_reviewed(), Err("No changed file is selected"));
|
||||
assert_eq!(
|
||||
state.toggle_reviewed(&mut review),
|
||||
Err("No changed file is selected")
|
||||
);
|
||||
state.files = Some(FilePicker::from_paths(
|
||||
PathBuf::new(),
|
||||
[(0, PathBuf::from("ordinary.txt"))],
|
||||
));
|
||||
assert_eq!(state.toggle_reviewed(), Err("No changed file is selected"));
|
||||
assert_eq!(
|
||||
state.toggle_reviewed(&mut review),
|
||||
Err("No changed file is selected")
|
||||
);
|
||||
state.focus = Focus::Before;
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
assert_eq!(state.toggle_reviewed(&mut review), Ok(true));
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
assert!(state.select_file_index(2));
|
||||
state.focus = Focus::Before;
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
assert_eq!(state.toggle_reviewed(&mut review), Ok(true));
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0, 2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewed_files_survive_lazy_details_and_views_but_reset_on_comparison() {
|
||||
let mut review = crate::review::ReviewState::default();
|
||||
let mut state = DiffState::new();
|
||||
assert_eq!(state.toggle_reviewed(), Err("No comparison is open"));
|
||||
assert_eq!(
|
||||
state.toggle_reviewed(&mut review),
|
||||
Err("No comparison is open")
|
||||
);
|
||||
let mut changes = comparison();
|
||||
changes.files[0].loaded = false;
|
||||
changes.files[0].before.clear();
|
||||
changes.files[0].after.clear();
|
||||
state.request_kind = Some(RequestKind::Comparison);
|
||||
state.apply_result(1, Ok(Outcome::Comparison(changes)));
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
assert_eq!(state.toggle_reviewed(&mut review), Ok(true));
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
let file = comparison().files.remove(0);
|
||||
state.apply_result(
|
||||
@@ -2667,10 +2679,13 @@ fn reviewed_files_survive_lazy_details_and_views_but_reset_on_comparison() {
|
||||
));
|
||||
state.toggle_comparison_mode().unwrap();
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
assert_eq!(state.toggle_reviewed(), Err("No comparison is open"));
|
||||
assert_eq!(
|
||||
state.toggle_reviewed(&mut review),
|
||||
Err("No comparison is open")
|
||||
);
|
||||
state.apply_result(3, Ok(Outcome::Comparison(comparison())));
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
assert_eq!(state.toggle_reviewed(&mut review), Ok(true));
|
||||
state.request_kind = Some(RequestKind::Comparison);
|
||||
state.apply_result(4, Ok(Outcome::Comparison(comparison())));
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
@@ -2678,17 +2693,18 @@ fn reviewed_files_survive_lazy_details_and_views_but_reset_on_comparison() {
|
||||
|
||||
#[test]
|
||||
fn restored_review_marks_use_file_identities_and_rename_paths_not_catalog_order() {
|
||||
let mut review = crate::review::ReviewState::default();
|
||||
let mut state = DiffState::new();
|
||||
state.request_kind = Some(RequestKind::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(&mut review), Ok(true));
|
||||
state.comparison.as_mut().unwrap().files.swap(0, 1);
|
||||
state.restore_reviewed_files();
|
||||
state.restore_reviewed_files(&review);
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([1]));
|
||||
state.comparison.as_mut().unwrap().files[1].old_path = Some("different.txt".into());
|
||||
state.restore_reviewed_files();
|
||||
state.restore_reviewed_files(&review);
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
state.comparison.as_mut().unwrap().files[1].old_path = Some("old.txt".into());
|
||||
for side in [0, 1] {
|
||||
@@ -2698,7 +2714,7 @@ fn restored_review_marks_use_file_identities_and_rename_paths_not_catalog_order(
|
||||
.unwrap();
|
||||
let original = identities[side].clone();
|
||||
identities[side].push_str("-different");
|
||||
state.restore_reviewed_files();
|
||||
state.restore_reviewed_files(&review);
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
state.comparison.as_mut().unwrap().files[1]
|
||||
.review_revisions
|
||||
@@ -2706,12 +2722,13 @@ fn restored_review_marks_use_file_identities_and_rename_paths_not_catalog_order(
|
||||
.unwrap()[side] = original;
|
||||
}
|
||||
state.comparison.as_mut().unwrap().files[1].loaded = false;
|
||||
state.restore_reviewed_files();
|
||||
state.restore_reviewed_files(&review);
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_file_review_survives_other_files_changing() {
|
||||
let mut review = crate::review::ReviewState::default();
|
||||
use crate::repository::Repository;
|
||||
use std::sync::{Arc, atomic::AtomicBool};
|
||||
let root = std::env::temp_dir().join(format!("vamp-review-files-{}", std::process::id()));
|
||||
@@ -2759,13 +2776,13 @@ fn unchanged_file_review_survives_other_files_changing() {
|
||||
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.toggle_reviewed(&mut review).unwrap();
|
||||
state.file = 1;
|
||||
state.focus = Focus::Before;
|
||||
state.toggle_reviewed().unwrap();
|
||||
state.toggle_reviewed(&mut review).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();
|
||||
store.save(&mut review.document).unwrap();
|
||||
let id = 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, &[])
|
||||
@@ -2773,8 +2790,8 @@ fn unchanged_file_review_survives_other_files_changing() {
|
||||
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();
|
||||
review.document = store.load(&root, id).unwrap();
|
||||
state.restore_reviewed_files(&review);
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
let detail = repository.load_file(2, 0).unwrap();
|
||||
state.apply_result(
|
||||
@@ -2785,33 +2802,36 @@ fn unchanged_file_review_survives_other_files_changing() {
|
||||
file: detail,
|
||||
}),
|
||||
);
|
||||
state.restore_reviewed_files();
|
||||
state.restore_reviewed_files(&review);
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_live_review_marks_require_exact_text_and_final_newline() {
|
||||
let mut review = crate::review::ReviewState::default();
|
||||
let mut reopened_review = crate::review::ReviewState::default();
|
||||
let mut state = live_state(vec!["base".into()], vec!["changed".into()], false);
|
||||
state.refresh_after(&["unsaved".into(), String::new()], 1);
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
let document = state.local_review.document.clone();
|
||||
state.refresh_after(&["unsaved".into(), String::new()], 1, &mut review);
|
||||
assert_eq!(state.toggle_reviewed(&mut review), Ok(true));
|
||||
let document = review.document.clone();
|
||||
let mut reopened = live_state(vec!["base".into()], vec!["changed".into()], false);
|
||||
reopened.local_review.document = document.clone();
|
||||
reopened.restore_reviewed_files();
|
||||
reopened_review.document = document.clone();
|
||||
reopened.restore_reviewed_files(&reopened_review);
|
||||
assert!(reopened.reviewed_files.is_empty());
|
||||
reopened.refresh_after(&["unsaved".into(), String::new()], 1);
|
||||
reopened.refresh_after(&["unsaved".into(), String::new()], 1, &mut reopened_review);
|
||||
assert_eq!(reopened.reviewed_files, BTreeSet::from([0]));
|
||||
reopened.refresh_after(&["unsaved".into()], 2);
|
||||
reopened.refresh_after(&["unsaved".into()], 2, &mut reopened_review);
|
||||
assert!(reopened.reviewed_files.is_empty());
|
||||
assert!(reopened.local_review.document.reviewed_files.is_empty());
|
||||
reopened.local_review.document = document;
|
||||
reopened.restore_reviewed_files();
|
||||
assert!(reopened_review.document.reviewed_files.is_empty());
|
||||
reopened_review.document = document;
|
||||
reopened.restore_reviewed_files(&reopened_review);
|
||||
assert!(reopened.reviewed_files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_review_marks_clear_only_for_changed_bytes_or_eof() {
|
||||
let mut review = crate::review::ReviewState::default();
|
||||
for (after, eof, lines) in [
|
||||
(vec!["changed".into()], false, vec!["changed".into()]),
|
||||
(
|
||||
@@ -2822,24 +2842,24 @@ fn working_review_marks_clear_only_for_changed_bytes_or_eof() {
|
||||
(Vec::new(), false, vec![String::new()]),
|
||||
] {
|
||||
let mut state = live_state(vec!["base".into()], after, eof);
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
state.refresh_after(&lines, 1);
|
||||
assert_eq!(state.toggle_reviewed(&mut review), Ok(true));
|
||||
state.refresh_after(&lines, 1, &mut review);
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
state.refresh_after(&lines, 2);
|
||||
state.refresh_after(&lines, 2, &mut review);
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
state.refresh_after(&["edited".into()], 3);
|
||||
state.refresh_after(&["edited".into()], 3, &mut review);
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
state.refresh_after(&["edited".into(), String::new()], 4);
|
||||
assert_eq!(state.toggle_reviewed(&mut review), Ok(true));
|
||||
state.refresh_after(&["edited".into(), String::new()], 4, &mut review);
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
state.refresh_after(&["edited".into()], 5);
|
||||
assert_eq!(state.toggle_reviewed(&mut review), Ok(true));
|
||||
state.refresh_after(&["edited".into()], 5, &mut review);
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
}
|
||||
let mut state = live_state(vec!["base".into()], vec!["changed".into()], false);
|
||||
state.comparison.as_mut().unwrap().files[0].loaded = false;
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
state.refresh_after(&["edited".into()], 1);
|
||||
assert_eq!(state.toggle_reviewed(&mut review), Ok(true));
|
||||
state.refresh_after(&["edited".into()], 1, &mut review);
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
}
|
||||
|
||||
@@ -2901,19 +2921,20 @@ fn unified_groups_changed_runs_and_roundtrips_side_specific_source_rows() {
|
||||
|
||||
#[test]
|
||||
fn unified_live_reflow_preserves_removed_top_and_expanded_source_excerpts() {
|
||||
let mut review = crate::review::ReviewState::default();
|
||||
let base: Vec<_> = (0..40).map(|row| format!("line {row}")).collect();
|
||||
let mut lines = base.clone();
|
||||
lines[20] = "new line".into();
|
||||
let mut state = live_state(base, lines.clone(), false);
|
||||
state.set_context_options(1, 3);
|
||||
state.refresh_after(&lines, 1);
|
||||
state.refresh_after(&lines, 1, &mut review);
|
||||
state.set_view_mode(ViewMode::Unified);
|
||||
state.top = state.source_display_row(Focus::Before, 20).unwrap();
|
||||
state.expand_source_excerpt(Focus::After, false, 20);
|
||||
assert_eq!(state.display_source_row(Focus::Before, state.top), Some(20));
|
||||
assert!(state.source_display_row(Focus::After, 16).is_some());
|
||||
lines.insert(0, "inserted".into());
|
||||
state.refresh_after(&lines, 2);
|
||||
state.refresh_after(&lines, 2, &mut review);
|
||||
assert_eq!(state.display_focus(state.top), Some(Focus::Before));
|
||||
assert_eq!(state.display_source_row(Focus::Before, state.top), Some(20));
|
||||
assert!(state.source_display_row(Focus::After, 17).is_some());
|
||||
@@ -2932,6 +2953,7 @@ fn unified_live_reflow_preserves_removed_top_and_expanded_source_excerpts() {
|
||||
|
||||
#[test]
|
||||
fn character_ranges_follow_insertions_deletions_unicode_and_live_edits() {
|
||||
let mut review = crate::review::ReviewState::default();
|
||||
for (before, after, expected) in [
|
||||
("ab", "a界b", vec![[1..1, 1..2]]),
|
||||
("a界b", "ab", vec![[1..2, 1..1]]),
|
||||
@@ -2941,17 +2963,18 @@ fn character_ranges_follow_insertions_deletions_unicode_and_live_edits() {
|
||||
assert_eq!(state.rows[0].changes, expected);
|
||||
}
|
||||
let mut state = live_state(vec!["abc".into()], vec!["aXbc".into()], false);
|
||||
state.refresh_after(&["aXYbc".into()], 1);
|
||||
state.refresh_after(&["aXYbc".into()], 1, &mut review);
|
||||
assert_eq!(state.rows[0].changes, vec![[1..1, 1..3]]);
|
||||
state.refresh_after(&["abYZc".into()], 2);
|
||||
state.refresh_after(&["abYZc".into()], 2, &mut review);
|
||||
assert_eq!(state.rows[0].changes, vec![[2..2, 2..4]]);
|
||||
state.refresh_after(&["abc".into()], 3);
|
||||
state.refresh_after(&["abc".into()], 3, &mut review);
|
||||
assert_eq!(state.rows[0].kind, RowKind::Equal);
|
||||
assert!(state.rows[0].changes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_highlights_merge_incidental_matches_but_preserve_meaningful_gaps() {
|
||||
let mut review = crate::review::ReviewState::default();
|
||||
let mut cases = vec![
|
||||
("a..c".into(), "b..d".into(), vec![[0..4, 0..4]]),
|
||||
("base".into(), "edited".into(), vec![[0..4, 0..6]]),
|
||||
@@ -2995,14 +3018,15 @@ fn character_highlights_merge_incidental_matches_but_preserve_meaningful_gaps()
|
||||
for (before, after, expected) in cases {
|
||||
let mut state = live_state(vec![before], vec![after.clone()], false);
|
||||
assert_eq!(state.rows[0].changes, expected, "{after:?}");
|
||||
state.refresh_after(&[format!("{after}!")], 1);
|
||||
state.refresh_after(std::slice::from_ref(&after), 2);
|
||||
state.refresh_after(&[format!("{after}!")], 1, &mut review);
|
||||
state.refresh_after(std::slice::from_ref(&after), 2, &mut review);
|
||||
assert_eq!(state.rows[0].changes, expected, "live edit: {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverting_loaded_file_keeps_unloaded_changes_selectable() {
|
||||
let mut review = crate::review::ReviewState::default();
|
||||
let mut state = live_state(vec!["base".into()], vec!["changed".into()], false);
|
||||
state.comparison_id = Some(17);
|
||||
state.pending = None;
|
||||
@@ -3020,7 +3044,7 @@ fn reverting_loaded_file_keeps_unloaded_changes_selectable() {
|
||||
hunks: vec![],
|
||||
notice: None,
|
||||
});
|
||||
state.refresh_after(&["base".into()], 1);
|
||||
state.refresh_after(&["base".into()], 1, &mut review);
|
||||
assert_eq!(
|
||||
state
|
||||
.files
|
||||
@@ -3043,36 +3067,37 @@ fn reverting_loaded_file_keeps_unloaded_changes_selectable() {
|
||||
|
||||
#[test]
|
||||
fn live_width_cache_scans_only_changed_lines_and_shrinks_after_deletion() {
|
||||
let mut review = crate::review::ReviewState::default();
|
||||
let base = vec!["original".into(), "medium".into(), "short".into()];
|
||||
let mut lines = vec!["界\tz".into(), "medium".into(), "short".into()];
|
||||
let mut state = live_state(base, lines.clone(), false);
|
||||
state.refresh_after(&lines, 1);
|
||||
state.refresh_after(&lines, 1, &mut review);
|
||||
assert_eq!(state.after_widths, [5, 6, 5]);
|
||||
lines[0] = "界界\t🙂".into();
|
||||
WIDTH_SCANS.set(0);
|
||||
state.refresh_after(&lines, 2);
|
||||
state.refresh_after(&lines, 2, &mut review);
|
||||
assert_eq!(WIDTH_SCANS.get(), 1);
|
||||
assert_eq!(state.max_after, 10);
|
||||
state.left_after = 9;
|
||||
lines[0] = "界".into();
|
||||
WIDTH_SCANS.set(0);
|
||||
state.refresh_after(&lines, 3);
|
||||
state.refresh_after(&lines, 3, &mut review);
|
||||
assert_eq!(WIDTH_SCANS.get(), 1);
|
||||
assert_eq!(state.max_after, 6);
|
||||
assert_eq!(state.left_after, 6);
|
||||
lines.remove(1);
|
||||
WIDTH_SCANS.set(0);
|
||||
state.refresh_after(&lines, 4);
|
||||
state.refresh_after(&lines, 4, &mut review);
|
||||
assert_eq!(WIDTH_SCANS.get(), 0);
|
||||
assert_eq!(state.after_widths, [2, 5]);
|
||||
assert_eq!(state.max_after, 5);
|
||||
lines.insert(1, "巨大\t🙂".into());
|
||||
WIDTH_SCANS.set(0);
|
||||
state.refresh_after(&lines, 5);
|
||||
state.refresh_after(&lines, 5, &mut review);
|
||||
assert_eq!(WIDTH_SCANS.get(), 1);
|
||||
assert_eq!(state.after_widths, [2, 10, 5]);
|
||||
lines = vec!["".into()];
|
||||
state.refresh_after(&lines, 6);
|
||||
state.refresh_after(&lines, 6, &mut review);
|
||||
assert_eq!(state.after_widths, [0]);
|
||||
assert_eq!(state.max_after, 0);
|
||||
assert_eq!(state.left_after, 0);
|
||||
@@ -3080,6 +3105,7 @@ fn live_width_cache_scans_only_changed_lines_and_shrinks_after_deletion() {
|
||||
|
||||
#[test]
|
||||
fn stable_line_edits_keep_fresh_hunks_expansion_and_scroll_anchor_until_revert() {
|
||||
let mut review = crate::review::ReviewState::default();
|
||||
let base = (0..100)
|
||||
.map(|row| format!("line {row}"))
|
||||
.collect::<Vec<_>>();
|
||||
@@ -3088,7 +3114,7 @@ fn stable_line_edits_keep_fresh_hunks_expansion_and_scroll_anchor_until_revert()
|
||||
let mut state = live_state(base.clone(), lines.clone(), true);
|
||||
state.set_context_options(2, 5);
|
||||
lines.push(String::new());
|
||||
state.refresh_after(&lines, 1);
|
||||
state.refresh_after(&lines, 1, &mut review);
|
||||
state.top = state.after_display_row(30).unwrap();
|
||||
state.handle(Input::Char('['));
|
||||
let display = state.display_rows.clone();
|
||||
@@ -3102,7 +3128,7 @@ fn stable_line_edits_keep_fresh_hunks_expansion_and_scroll_anchor_until_revert()
|
||||
changed_line_range(&file.after, &lines)
|
||||
));
|
||||
WIDTH_SCANS.set(0);
|
||||
state.refresh_after(&lines, 2);
|
||||
state.refresh_after(&lines, 2, &mut review);
|
||||
assert_eq!(WIDTH_SCANS.get(), 1);
|
||||
assert_eq!(state.display_rows, display);
|
||||
assert_eq!(state.excerpt_ranges, excerpts);
|
||||
@@ -3131,14 +3157,14 @@ fn stable_line_edits_keep_fresh_hunks_expansion_and_scroll_anchor_until_revert()
|
||||
&lines,
|
||||
changed_line_range(&file.after, &lines)
|
||||
));
|
||||
state.refresh_after(&lines, 3);
|
||||
state.refresh_after(&lines, 3, &mut review);
|
||||
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty());
|
||||
assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal));
|
||||
assert!(state.after_display_row(23).is_some());
|
||||
assert_eq!(state.top, state.after_display_row(30).unwrap());
|
||||
assert_eq!(state.files.as_ref().unwrap().entries().len(), 0);
|
||||
lines[50] = "changed again".into();
|
||||
state.refresh_after(&lines, 4);
|
||||
state.refresh_after(&lines, 4, &mut review);
|
||||
assert_eq!(state.files.as_ref().unwrap().entries().len(), 1);
|
||||
assert!(state.after_display_row(23).is_some());
|
||||
assert!(state.after_display_row(50).is_some());
|
||||
@@ -3146,6 +3172,7 @@ fn stable_line_edits_keep_fresh_hunks_expansion_and_scroll_anchor_until_revert()
|
||||
|
||||
#[test]
|
||||
fn live_reflow_keeps_separate_hunks_and_expanded_context_after_revert() {
|
||||
let mut review = crate::review::ReviewState::default();
|
||||
let base = (0..100)
|
||||
.map(|row| format!("line {row}"))
|
||||
.collect::<Vec<_>>();
|
||||
@@ -3155,7 +3182,7 @@ fn live_reflow_keeps_separate_hunks_and_expanded_context_after_revert() {
|
||||
let mut state = live_state(base.clone(), changed.clone(), true);
|
||||
state.set_context_options(2, 5);
|
||||
changed.push(String::new());
|
||||
state.refresh_after(&changed, 1);
|
||||
state.refresh_after(&changed, 1, &mut review);
|
||||
assert_eq!(state.comparison.as_ref().unwrap().files[0].hunks.len(), 2);
|
||||
assert!(
|
||||
state
|
||||
@@ -3167,7 +3194,7 @@ fn live_reflow_keeps_separate_hunks_and_expanded_context_after_revert() {
|
||||
state.handle(Input::Char('['));
|
||||
assert!(state.after_display_row(23).is_some());
|
||||
changed.insert(10, "inserted outside excerpts".into());
|
||||
state.refresh_after(&changed, 2);
|
||||
state.refresh_after(&changed, 2, &mut review);
|
||||
assert!(state.after_display_row(24).is_some());
|
||||
assert_eq!(state.comparison.as_ref().unwrap().files[0].hunks.len(), 3);
|
||||
assert!(
|
||||
@@ -3178,7 +3205,7 @@ fn live_reflow_keeps_separate_hunks_and_expanded_context_after_revert() {
|
||||
);
|
||||
let mut reverted = base;
|
||||
reverted.push(String::new());
|
||||
state.refresh_after(&reverted, 3);
|
||||
state.refresh_after(&reverted, 3, &mut review);
|
||||
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty());
|
||||
assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal));
|
||||
assert!(state.after_display_row(23).is_some());
|
||||
@@ -3186,21 +3213,22 @@ fn live_reflow_keeps_separate_hunks_and_expanded_context_after_revert() {
|
||||
assert_eq!(state.files.as_ref().unwrap().entries().len(), 0);
|
||||
assert_eq!(state.file, 0);
|
||||
reverted[50] = "changed again".into();
|
||||
state.refresh_after(&reverted, 4);
|
||||
state.refresh_after(&reverted, 4, &mut review);
|
||||
assert_eq!(state.files.as_ref().unwrap().entries().len(), 1);
|
||||
assert!(state.after_display_row(50).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_empty_and_eof_rows_match_buffer_without_false_eof_highlights() {
|
||||
let mut review = crate::review::ReviewState::default();
|
||||
let mut state = live_state(vec!["same".into()], vec!["changed".into()], true);
|
||||
state.refresh_after(&["same".into(), "".into()], 1);
|
||||
state.refresh_after(&["same".into(), "".into()], 1, &mut review);
|
||||
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty());
|
||||
assert_eq!(state.rows.len(), 2);
|
||||
assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal));
|
||||
assert!(state.after_display_row(0).is_some());
|
||||
assert!(state.after_display_row(1).is_none());
|
||||
state.refresh_after(&["same".into()], 2);
|
||||
state.refresh_after(&["same".into()], 2, &mut review);
|
||||
assert_eq!(state.rows[0].kind, RowKind::Changed);
|
||||
assert!(
|
||||
state.comparison.as_ref().unwrap().files[0]
|
||||
@@ -3209,18 +3237,18 @@ fn live_empty_and_eof_rows_match_buffer_without_false_eof_highlights() {
|
||||
.unwrap()
|
||||
.contains("After missing")
|
||||
);
|
||||
state.refresh_after(&["".into()], 3);
|
||||
state.refresh_after(&["".into()], 3, &mut review);
|
||||
assert!(state.after_display_row(0).is_some());
|
||||
assert_eq!(state.comparison.as_ref().unwrap().files[0].after, [""]);
|
||||
let mut empty = live_state(vec![], vec!["text".into()], false);
|
||||
empty.refresh_after(&["".into()], 1);
|
||||
empty.refresh_after(&["".into()], 1, &mut review);
|
||||
assert!(empty.comparison.as_ref().unwrap().files[0].hunks.is_empty());
|
||||
assert_eq!(empty.rows.len(), 1);
|
||||
assert_eq!(empty.rows[0].kind, RowKind::Equal);
|
||||
assert_eq!(empty.after_display_row(0), Some(0));
|
||||
empty.after.as_mut().unwrap().target = RevisionTarget::Commit("historic".into());
|
||||
empty.comparison.as_mut().unwrap().editable_paths.clear();
|
||||
empty.refresh_after(&["must not apply".into()], 2);
|
||||
empty.refresh_after(&["must not apply".into()], 2, &mut review);
|
||||
assert_eq!(empty.comparison.as_ref().unwrap().files[0].after, [""]);
|
||||
empty.handle(Input::Escape);
|
||||
assert_eq!(empty.phase, Phase::View);
|
||||
@@ -3372,6 +3400,7 @@ fn mode_action_keeps_filter_input_and_rejects_superseded_comparisons() {
|
||||
|
||||
#[test]
|
||||
fn review_anchors_match_frozen_context_and_exact_live_eof_without_allocating() {
|
||||
let review = crate::review::ReviewState::default();
|
||||
use crate::review::{Anchor, Side};
|
||||
let mut state = live_state(vec!["old".into()], vec!["new".into()], true);
|
||||
let anchor = Anchor {
|
||||
@@ -3382,15 +3411,15 @@ fn review_anchors_match_frozen_context_and_exact_live_eof_without_allocating() {
|
||||
end: crate::plugin::TextPosition { row: 1, column: 0 },
|
||||
content: "new\n".into(),
|
||||
};
|
||||
assert!(state.review_anchor_range(&anchor).is_some());
|
||||
assert!(state.review_anchor_range(&anchor, &review).is_some());
|
||||
state.live_files.insert(0);
|
||||
assert!(state.review_anchor_range(&anchor).is_none());
|
||||
assert!(state.review_anchor_range(&anchor, &review).is_none());
|
||||
state.comparison.as_mut().unwrap().files[0]
|
||||
.after
|
||||
.push(String::new());
|
||||
assert!(state.review_anchor_range(&anchor).is_some());
|
||||
assert!(state.review_anchor_range(&anchor, &review).is_some());
|
||||
state.comparison.as_mut().unwrap().files[0].after[0] = "changed".into();
|
||||
assert!(state.review_anchor_range(&anchor).is_none());
|
||||
assert!(state.review_anchor_range(&anchor, &review).is_none());
|
||||
state.comparison.as_mut().unwrap().files[0].old_path = Some("original".into());
|
||||
let before = Anchor {
|
||||
revision: "base".into(),
|
||||
@@ -3399,12 +3428,12 @@ fn review_anchors_match_frozen_context_and_exact_live_eof_without_allocating() {
|
||||
content: "old\n".into(),
|
||||
..anchor
|
||||
};
|
||||
assert!(state.review_anchor_range(&before).is_some());
|
||||
assert!(state.review_anchor_range(&before, &review).is_some());
|
||||
state.comparison.as_mut().unwrap().files[0].loaded = false;
|
||||
assert!(state.review_anchor_range(&before).is_none());
|
||||
assert!(state.review_anchor_range(&before, &review).is_none());
|
||||
state.comparison.as_mut().unwrap().files[0].loaded = true;
|
||||
state.comparison.as_mut().unwrap().revisions[0] = "other".into();
|
||||
assert!(state.review_anchor_range(&before).is_none());
|
||||
assert!(state.review_anchor_range(&before, &review).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+274
-241
@@ -1205,11 +1205,11 @@ fn command_completion_respects_bindings_and_preserves_visual_diff_and_search_inp
|
||||
editor.handle(Input::Char('V'));
|
||||
let selections = editor.selections();
|
||||
let cursor = editor.cursor();
|
||||
type_text(&mut editor, ":diff.com");
|
||||
type_text(&mut editor, ":comm");
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert_eq!(editor.command_line(), "diff.com");
|
||||
assert_eq!(editor.command_line(), "comm");
|
||||
editor.handle(Input::Ctrl('n'));
|
||||
assert_eq!(editor.command_line(), "diff.comment");
|
||||
assert_eq!(editor.command_line(), "comment");
|
||||
assert_eq!(editor.selections(), selections);
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
editor.handle(Input::Escape);
|
||||
@@ -3139,6 +3139,160 @@ fn historical_diff(editor: &mut Editor, root: &Path, before: Vec<String>, after:
|
||||
assert!(editor.diff_interacting());
|
||||
}
|
||||
|
||||
fn source_review_editor(directory: &Directory, text: &str) -> (Editor, PathBuf, PathBuf) {
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
assert!(
|
||||
std::process::Command::new("git")
|
||||
.args(["init", "-q", "--template="])
|
||||
.current_dir(&root)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
let path = root.join("source.txt");
|
||||
fs::write(&path, text).unwrap();
|
||||
let mut editor = Editor::open(Some(path.clone()), vec!["text".into()]).unwrap();
|
||||
editor.set_clipboard_mode("separate").unwrap();
|
||||
editor.set_review_directory(root.join("reviews"));
|
||||
(editor, root, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_review_drafts_guard_source_pane_close_and_saved_panels_retarget() {
|
||||
let directory = Directory::new();
|
||||
let (mut editor, _, _) = source_review_editor(&directory, "source\n");
|
||||
let source = editor.pane_id;
|
||||
editor.split_pane(SplitAxis::Vertical);
|
||||
let other = editor.pane_id;
|
||||
editor.select_pane(source);
|
||||
editor.execute("comment");
|
||||
type_text(&mut editor, "iDraft");
|
||||
editor.handle(Input::Escape);
|
||||
editor.select_pane(source);
|
||||
editor.close_pane(false);
|
||||
assert!(editor.panes.contains_key(&source));
|
||||
editor.execute("review.save");
|
||||
editor.close_pane(false);
|
||||
assert!(!editor.panes.contains_key(&source));
|
||||
assert_eq!(editor.pane_id, other);
|
||||
editor.execute("review.edit 1");
|
||||
assert_eq!(editor.context().text, "Draft");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.pane_id, other);
|
||||
assert_eq!(editor.context().text, "source\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_comments_capture_unsaved_exact_unicode_and_reopen_without_a_diff() {
|
||||
let directory = Directory::new();
|
||||
let (mut editor, root, path) = source_review_editor(&directory, "disk\n");
|
||||
assert!(!root.join("reviews").exists());
|
||||
let source = "first\nprefix e\u{301}界 suffix\nlast\n";
|
||||
editor.apply(vec![Effect::Replace(source.into())]).unwrap();
|
||||
set_carets(&mut editor, &[(1, 7)], 0);
|
||||
type_text(&mut editor, "vl");
|
||||
editor.execute("comment");
|
||||
assert!(editor.review_panel().is_some());
|
||||
assert!(editor.workspace_diff().is_none());
|
||||
type_text(&mut editor, "iExact feedback");
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute("w");
|
||||
let review = editor.local_review.document.clone();
|
||||
let anchor = review.comments[0].anchor.clone();
|
||||
assert_eq!(anchor.excerpt(), "e\u{301}界");
|
||||
assert_eq!(anchor.content, source);
|
||||
assert!(anchor.revision.starts_with("working:"));
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "disk\n");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, source);
|
||||
editor.execute("w");
|
||||
drop(editor);
|
||||
let mut reopened = Editor::open(Some(path), vec!["text".into()]).unwrap();
|
||||
reopened.set_review_directory(root.join("reviews"));
|
||||
assert!(reopened.workspace_diff().is_none());
|
||||
assert_eq!(reopened.local_review.document.storage_id, review.storage_id);
|
||||
reopened.execute("review.next");
|
||||
assert_eq!(reopened.context().text, "Exact feedback");
|
||||
assert!(reopened.review_panel_title().unwrap().contains("current"));
|
||||
reopened.handle(Input::Escape);
|
||||
assert_eq!(reopened.cursor(), Cursor { row: 1, column: 7 });
|
||||
assert_eq!(reopened.local_review.document.comments[0].anchor, anchor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_comment_dismissal_conflicts_tabs_and_stale_snapshots_preserve_text() {
|
||||
let directory = Directory::new();
|
||||
let (mut editor, root, path) = source_review_editor(&directory, "original line\n");
|
||||
editor.execute("comment");
|
||||
type_text(&mut editor, "iDiscarded");
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.local_review.document.comments.is_empty());
|
||||
editor.execute("comment");
|
||||
type_text(&mut editor, "iSaved feedback");
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute("w");
|
||||
editor.handle(Input::Escape);
|
||||
let anchor = editor.local_review.document.comments[0].anchor.clone();
|
||||
assert_eq!(anchor.excerpt(), "original line\n");
|
||||
editor.execute("tab.new");
|
||||
editor.execute("review.edit 1");
|
||||
assert_eq!(editor.context().text, "Saved feedback");
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute("tab.previous");
|
||||
editor
|
||||
.apply(vec![Effect::Replace("entirely changed\n".into())])
|
||||
.unwrap();
|
||||
editor.execute("review.edit 1");
|
||||
assert!(editor.message().contains("stale"));
|
||||
assert!(
|
||||
editor
|
||||
.review_panel_title()
|
||||
.unwrap()
|
||||
.contains("original snapshot")
|
||||
);
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, "original line\n");
|
||||
assert!(editor.active.resolver_readonly);
|
||||
assert_eq!(editor.local_review.document.comments[0].anchor, anchor);
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "original line\n");
|
||||
let mut concurrent = Editor::open(Some(path), vec!["text".into()]).unwrap();
|
||||
concurrent.set_review_directory(root.join("reviews"));
|
||||
concurrent.execute("review.verdict looks_good");
|
||||
editor.execute("review.edit 1");
|
||||
type_text(&mut editor, "A unsaved");
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute("w");
|
||||
assert!(editor.message().contains("changed in another session"));
|
||||
assert_eq!(editor.context().text, "Saved feedback unsaved");
|
||||
assert!(editor.current_review_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_comments_reject_multiple_carets_and_roundtrip_json_with_empty_lines() {
|
||||
let directory = Directory::new();
|
||||
let (mut editor, root, _) = source_review_editor(&directory, "one\n\n");
|
||||
set_carets(&mut editor, &[(0, 0), (1, 0)], 0);
|
||||
editor.execute("comment");
|
||||
assert!(editor.message().contains("one caret"));
|
||||
assert!(editor.review_panel().is_none());
|
||||
set_carets(&mut editor, &[(1, 0)], 0);
|
||||
editor.execute("comment");
|
||||
type_text(&mut editor, "iEmpty line");
|
||||
editor.handle(Input::Escape);
|
||||
let export = root.join("review.json");
|
||||
editor.execute(&format!("review.save {}", export.display()));
|
||||
let exported = crate::review::Review::load(&export, &root).unwrap();
|
||||
assert_eq!(exported.comments[0].anchor.excerpt(), "\n");
|
||||
assert_eq!(exported.comments[0].body, "Empty line");
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute(&format!("review.open {}", export.display()));
|
||||
assert_eq!(
|
||||
editor.local_review.document.comments[0].anchor,
|
||||
exported.comments[0].anchor
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsaved_review_comments_are_discarded_on_escape() {
|
||||
let directory = Directory::new();
|
||||
@@ -3147,19 +3301,11 @@ fn unsaved_review_comments_are_discarded_on_escape() {
|
||||
let mut editor = editor();
|
||||
editor.set_review_directory(storage.clone());
|
||||
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
|
||||
editor.execute("diff.comment");
|
||||
editor.execute("comment");
|
||||
type_text(&mut editor, "iDiscard me");
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Escape);
|
||||
assert!(
|
||||
editor
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.comments
|
||||
.is_empty()
|
||||
);
|
||||
assert!(editor.local_review.document.comments.is_empty());
|
||||
assert!(
|
||||
crate::review::Store::open(&storage)
|
||||
.unwrap()
|
||||
@@ -3167,17 +3313,17 @@ fn unsaved_review_comments_are_discarded_on_escape() {
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
editor.execute("diff.comment");
|
||||
editor.execute("comment");
|
||||
assert_eq!(editor.context().text, "");
|
||||
type_text(&mut editor, "iKeep me");
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute("w");
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute("diff.review.edit 1");
|
||||
editor.execute("review.edit 1");
|
||||
type_text(&mut editor, "A discarded");
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute("diff.review.edit 1");
|
||||
editor.execute("review.edit 1");
|
||||
assert_eq!(editor.context().text, "Keep me");
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.context().text, "Keep me");
|
||||
@@ -3196,13 +3342,13 @@ fn review_metadata_autosaves_do_not_commit_hidden_comment_or_summary_drafts() {
|
||||
let mut editor = editor();
|
||||
editor.set_review_directory(storage.clone());
|
||||
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
|
||||
editor.execute("diff.comment");
|
||||
editor.execute("comment");
|
||||
type_text(&mut editor, "iHidden comment");
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute("diff.review.summary");
|
||||
editor.execute("review.summary");
|
||||
type_text(&mut editor, "iHidden summary");
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute("diff.review.verdict looks_good");
|
||||
editor.execute("review.verdict looks_good");
|
||||
let store = crate::review::Store::open(&storage).unwrap();
|
||||
let saved = store.list(&root).unwrap().pop().unwrap();
|
||||
assert!(saved.comments.is_empty());
|
||||
@@ -3230,12 +3376,12 @@ fn reviewed_file_marks_autosave_restore_and_stay_with_the_selected_review() {
|
||||
let id = saved.storage_id.unwrap();
|
||||
assert_eq!(saved.reviewed_files[0].path, Path::new("file.txt"));
|
||||
let export = root.join("review.json");
|
||||
first.execute(&format!("diff.review.save {}", export.display()));
|
||||
first.execute(&format!("review.save {}", export.display()));
|
||||
assert_eq!(
|
||||
Review::load(&export, &root).unwrap().reviewed_files,
|
||||
saved.reviewed_files
|
||||
);
|
||||
first.execute("diff.review.summary");
|
||||
first.execute("review.summary");
|
||||
type_text(&mut first, "iUnsaved summary");
|
||||
first.handle(Input::Escape);
|
||||
first.execute("diff.review.toggle");
|
||||
@@ -3250,9 +3396,9 @@ fn reviewed_file_marks_autosave_restore_and_stay_with_the_selected_review() {
|
||||
reopened.workspace_diff().unwrap().reviewed_files,
|
||||
BTreeSet::from([0])
|
||||
);
|
||||
reopened.execute("diff.review.new");
|
||||
reopened.execute("review.new");
|
||||
assert!(reopened.workspace_diff().unwrap().reviewed_files.is_empty());
|
||||
reopened.execute(&format!("diff.review.open {id}"));
|
||||
reopened.execute(&format!("review.open {id}"));
|
||||
assert_eq!(
|
||||
reopened.workspace_diff().unwrap().reviewed_files,
|
||||
BTreeSet::from([0])
|
||||
@@ -3299,7 +3445,7 @@ fn review_navigation_maps_working_ranges_and_preserves_stale_snapshots() {
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.revisions[1] = "working:old".into();
|
||||
editor.diff.as_mut().unwrap().local_review.document = crate::review::Review::new(root);
|
||||
editor.local_review.document = crate::review::Review::new(root);
|
||||
let anchor = Anchor {
|
||||
revision: "working:old".into(),
|
||||
path: "file.txt".into(),
|
||||
@@ -3308,19 +3454,12 @@ fn review_navigation_maps_working_ranges_and_preserves_stale_snapshots() {
|
||||
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.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(),
|
||||
@@ -3335,14 +3474,11 @@ fn review_navigation_maps_working_ranges_and_preserves_stale_snapshots() {
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.revisions[1] = "working:new".into();
|
||||
editor.execute("diff.review.edit 1");
|
||||
editor.execute("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
|
||||
);
|
||||
assert_eq!(editor.local_review.document.comments[0].anchor, anchor);
|
||||
|
||||
editor
|
||||
.apply(vec![Effect::Replace(
|
||||
@@ -3351,14 +3487,11 @@ fn review_navigation_maps_working_ranges_and_preserves_stale_snapshots() {
|
||||
.unwrap();
|
||||
editor.refresh_live_diff();
|
||||
set_carets(&mut editor, &[(0, 0)], 0);
|
||||
editor.execute("diff.review.edit 1");
|
||||
editor.execute("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
|
||||
);
|
||||
assert_eq!(editor.local_review.document.comments[0].anchor, anchor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3375,7 +3508,7 @@ fn sqlite_reviews_autosave_restore_select_and_keep_exports_independent() {
|
||||
vec!["p界e\u{301}z".into()],
|
||||
vec!["changed".into()],
|
||||
);
|
||||
type_text(&mut first, "lvl:diff.comment");
|
||||
type_text(&mut first, "lvl:comment");
|
||||
first.handle(Input::Enter);
|
||||
type_text(&mut first, "iFirst feedback");
|
||||
first.handle(Input::Escape);
|
||||
@@ -3388,13 +3521,7 @@ fn sqlite_reviews_autosave_restore_select_and_keep_exports_independent() {
|
||||
);
|
||||
first.execute("w");
|
||||
first.handle(Input::Escape);
|
||||
let id = first
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.storage_id
|
||||
.unwrap();
|
||||
let id = first.local_review.document.storage_id.unwrap();
|
||||
let store = Store::open(&storage).unwrap();
|
||||
let persisted = store.load(&root, id).unwrap();
|
||||
assert_eq!(persisted.comments[0].body, "First feedback");
|
||||
@@ -3406,17 +3533,17 @@ fn sqlite_reviews_autosave_restore_select_and_keep_exports_independent() {
|
||||
persisted.comments[0].anchor.end,
|
||||
TextPosition { row: 0, column: 3 }
|
||||
);
|
||||
first.execute("diff.review.summary");
|
||||
first.execute("review.summary");
|
||||
type_text(&mut first, "iSummary");
|
||||
first.handle(Input::Escape);
|
||||
first.execute("w");
|
||||
first.execute("diff.review.verdict changes_requested");
|
||||
first.execute("review.verdict changes_requested");
|
||||
let persisted = store.load(&root, id).unwrap();
|
||||
assert_eq!(persisted.summary, "Summary");
|
||||
assert_eq!(persisted.verdict, Some(Verdict::ChangesRequested));
|
||||
let exported = root.join("shared.json");
|
||||
first.execute(&format!("diff.review.save {}", exported.display()));
|
||||
first.execute("diff.review.edit 1");
|
||||
first.execute(&format!("review.save {}", exported.display()));
|
||||
first.execute("review.edit 1");
|
||||
type_text(&mut first, "A revised");
|
||||
first.handle(Input::Escape);
|
||||
first.execute("w");
|
||||
@@ -3437,58 +3564,30 @@ fn sqlite_reviews_autosave_restore_select_and_keep_exports_independent() {
|
||||
vec!["different revision".into()],
|
||||
vec!["changed".into()],
|
||||
);
|
||||
let restored = &reopened.workspace_diff().unwrap().local_review.document;
|
||||
let restored = &reopened.local_review.document;
|
||||
assert_eq!(restored.storage_id, Some(id));
|
||||
assert_eq!(restored.comments[0].body, "First feedback revised");
|
||||
assert!(
|
||||
reopened
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.review_anchor_range(&restored.comments[0].anchor)
|
||||
.review_anchor_range(&restored.comments[0].anchor, &reopened.local_review)
|
||||
.is_none()
|
||||
);
|
||||
reopened.execute("diff.review.new");
|
||||
let second_id = reopened
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.storage_id
|
||||
.unwrap();
|
||||
reopened.execute("review.new");
|
||||
let second_id = reopened.local_review.document.storage_id.unwrap();
|
||||
assert_ne!(id, second_id);
|
||||
let mut choosing = editor();
|
||||
choosing.set_review_directory(storage);
|
||||
historical_diff(&mut choosing, &root, vec!["old".into()], vec!["new".into()]);
|
||||
choosing.handle(Input::Enter);
|
||||
assert_eq!(
|
||||
choosing
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.storage_id,
|
||||
Some(second_id)
|
||||
);
|
||||
choosing.execute(&format!("diff.review.open {id}"));
|
||||
assert_eq!(
|
||||
choosing
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.storage_id,
|
||||
Some(id)
|
||||
);
|
||||
choosing.execute("diff.review.delete 1");
|
||||
assert_eq!(choosing.local_review.document.storage_id, Some(second_id));
|
||||
choosing.execute(&format!("review.open {id}"));
|
||||
assert_eq!(choosing.local_review.document.storage_id, Some(id));
|
||||
choosing.execute("review.delete 1");
|
||||
assert!(store.load(&root, id).unwrap().comments.is_empty());
|
||||
choosing.execute(&format!("diff.review.open {}", exported.display()));
|
||||
let imported_id = choosing
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.storage_id
|
||||
.unwrap();
|
||||
choosing.execute(&format!("review.open {}", exported.display()));
|
||||
let imported_id = choosing.local_review.document.storage_id.unwrap();
|
||||
assert_ne!(imported_id, id);
|
||||
assert_eq!(
|
||||
store.load(&root, imported_id).unwrap().comments[0].body,
|
||||
@@ -3505,7 +3604,7 @@ fn review_resolution_persists_without_hiding_comments_or_changing_stale_anchors(
|
||||
let mut first = editor();
|
||||
first.set_review_directory(storage.clone());
|
||||
historical_diff(&mut first, &root, vec!["old".into()], vec!["new".into()]);
|
||||
first.execute("diff.comment");
|
||||
first.execute("comment");
|
||||
type_text(&mut first, "iFeedback");
|
||||
first.handle(Input::Escape);
|
||||
first.execute("w");
|
||||
@@ -3515,7 +3614,7 @@ fn review_resolution_persists_without_hiding_comments_or_changing_stale_anchors(
|
||||
.unwrap()
|
||||
.contains(" · unresolved · ")
|
||||
);
|
||||
first.execute("diff.review.resolve");
|
||||
first.execute("review.resolve");
|
||||
assert!(
|
||||
first
|
||||
.context()
|
||||
@@ -3523,11 +3622,11 @@ fn review_resolution_persists_without_hiding_comments_or_changing_stale_anchors(
|
||||
.contains("Comment #1 · resolved · current")
|
||||
);
|
||||
let store = Store::open(&storage).unwrap();
|
||||
let document = &first.workspace_diff().unwrap().local_review.document;
|
||||
let document = &first.local_review.document;
|
||||
let id = document.storage_id.unwrap();
|
||||
let anchor = document.comments[0].anchor.clone();
|
||||
assert!(store.load(&root, id).unwrap().comments[0].resolved);
|
||||
first.execute("diff.review.next");
|
||||
first.execute("review.next");
|
||||
assert_eq!(first.context().text, "Feedback");
|
||||
assert!(
|
||||
first
|
||||
@@ -3536,7 +3635,7 @@ fn review_resolution_persists_without_hiding_comments_or_changing_stale_anchors(
|
||||
.contains(" · resolved · ")
|
||||
);
|
||||
let export = root.join("resolved.json");
|
||||
first.execute(&format!("diff.review.save {}", export.display()));
|
||||
first.execute(&format!("review.save {}", export.display()));
|
||||
assert!(Review::load(&export, &root).unwrap().comments[0].resolved);
|
||||
let mut stale = editor();
|
||||
stale.set_review_directory(storage.clone());
|
||||
@@ -3546,53 +3645,36 @@ fn review_resolution_persists_without_hiding_comments_or_changing_stale_anchors(
|
||||
vec!["changed".into()],
|
||||
vec!["new".into()],
|
||||
);
|
||||
stale.execute("diff.review.overview");
|
||||
stale.execute("review.overview");
|
||||
assert!(
|
||||
stale
|
||||
.context()
|
||||
.text
|
||||
.contains("Comment #1 · resolved · stale")
|
||||
);
|
||||
first.execute("diff.review.unresolve 1");
|
||||
first.execute("review.unresolve 1");
|
||||
assert!(!store.load(&root, id).unwrap().comments[0].resolved);
|
||||
stale.execute("diff.review.unresolve 1");
|
||||
stale.execute("review.unresolve 1");
|
||||
assert!(stale.current_review_dirty());
|
||||
assert!(
|
||||
!stale
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.comments[0]
|
||||
.resolved
|
||||
);
|
||||
assert_eq!(
|
||||
stale
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.comments[0]
|
||||
.anchor,
|
||||
anchor
|
||||
);
|
||||
assert!(!stale.local_review.document.comments[0].resolved);
|
||||
assert_eq!(stale.local_review.document.comments[0].anchor, anchor);
|
||||
assert!(stale.message().contains("changed in another session"));
|
||||
first.execute("diff.review.resolve");
|
||||
first.execute("review.resolve");
|
||||
assert!(
|
||||
first
|
||||
.context()
|
||||
.text
|
||||
.contains("Comment #1 · resolved · current")
|
||||
);
|
||||
first.execute("diff.review.unresolve");
|
||||
first.execute("review.unresolve");
|
||||
assert!(
|
||||
first
|
||||
.context()
|
||||
.text
|
||||
.contains("Comment #1 · unresolved · current")
|
||||
);
|
||||
first.execute(&format!("diff.review.open {}", export.display()));
|
||||
let imported = &first.workspace_diff().unwrap().local_review.document;
|
||||
first.execute(&format!("review.open {}", export.display()));
|
||||
let imported = &first.local_review.document;
|
||||
assert_ne!(imported.storage_id, Some(id));
|
||||
assert!(
|
||||
store
|
||||
@@ -3612,39 +3694,20 @@ fn sqlite_review_conflicts_and_write_failures_keep_dirty_drafts() {
|
||||
let mut first = editor();
|
||||
first.set_review_directory(storage.clone());
|
||||
historical_diff(&mut first, &root, vec!["old".into()], vec!["new".into()]);
|
||||
first.execute("diff.comment");
|
||||
first.execute("comment");
|
||||
type_text(&mut first, "iStored feedback");
|
||||
first.handle(Input::Escape);
|
||||
first.execute("w");
|
||||
first.handle(Input::Escape);
|
||||
let id = first
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.storage_id
|
||||
.unwrap();
|
||||
let id = first.local_review.document.storage_id.unwrap();
|
||||
let mut stale = editor();
|
||||
stale.set_review_directory(storage.clone());
|
||||
historical_diff(&mut stale, &root, vec!["old".into()], vec!["new".into()]);
|
||||
let version = stale
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.storage_revision;
|
||||
first.execute("diff.review.verdict looks_good");
|
||||
stale.execute("diff.review.verdict changes_requested");
|
||||
let version = stale.local_review.document.storage_revision;
|
||||
first.execute("review.verdict looks_good");
|
||||
stale.execute("review.verdict changes_requested");
|
||||
assert!(stale.current_review_dirty());
|
||||
assert_eq!(
|
||||
stale
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.storage_revision,
|
||||
version
|
||||
);
|
||||
assert_eq!(stale.local_review.document.storage_revision, version);
|
||||
assert_eq!(
|
||||
Store::open(&storage)
|
||||
.unwrap()
|
||||
@@ -3656,7 +3719,7 @@ fn sqlite_review_conflicts_and_write_failures_keep_dirty_drafts() {
|
||||
let blocked = root.join("not-a-directory");
|
||||
fs::write(&blocked, "preserve").unwrap();
|
||||
first.set_review_directory(blocked.clone());
|
||||
first.execute("diff.review.edit 1");
|
||||
first.execute("review.edit 1");
|
||||
type_text(&mut first, "A unsaved");
|
||||
first.handle(Input::Escape);
|
||||
first.execute("w");
|
||||
@@ -3665,13 +3728,7 @@ fn sqlite_review_conflicts_and_write_failures_keep_dirty_drafts() {
|
||||
first.handle(Input::Escape);
|
||||
assert!(!first.current_review_dirty());
|
||||
assert_eq!(
|
||||
first
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.comments[0]
|
||||
.body,
|
||||
first.local_review.document.comments[0].body,
|
||||
"Stored feedback"
|
||||
);
|
||||
assert_eq!(fs::read_to_string(blocked).unwrap(), "preserve");
|
||||
@@ -3701,7 +3758,7 @@ fn local_review_native_composition_roundtrip_and_draft_lifecycle() {
|
||||
);
|
||||
let owner = editor.pane_id();
|
||||
let source = editor.context().text;
|
||||
editor.execute("diff.comment");
|
||||
editor.execute("comment");
|
||||
assert_ne!(editor.pane_id(), owner);
|
||||
assert!(
|
||||
editor
|
||||
@@ -3711,12 +3768,12 @@ fn local_review_native_composition_roundtrip_and_draft_lifecycle() {
|
||||
);
|
||||
type_text(&mut editor, "iExplain the removed branch");
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute("diff.review.summary");
|
||||
editor.execute("review.summary");
|
||||
type_text(&mut editor, "iPlease simplify the branch");
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute("diff.review.verdict changes_requested");
|
||||
editor.execute("review.verdict changes_requested");
|
||||
assert!(editor.context().text.contains("Changes requested"));
|
||||
editor.execute(&format!("diff.review.save {}", path.display()));
|
||||
editor.execute(&format!("review.save {}", path.display()));
|
||||
let review = Review::load(&path, &root).unwrap();
|
||||
assert_eq!(review.comments[0].body, "Explain the removed branch");
|
||||
assert_eq!(review.comments[0].anchor.side, Side::Before);
|
||||
@@ -3724,7 +3781,7 @@ fn local_review_native_composition_roundtrip_and_draft_lifecycle() {
|
||||
assert_eq!(review.summary, "Please simplify the branch");
|
||||
assert_eq!(review.verdict, Some(Verdict::ChangesRequested));
|
||||
assert!(!editor.local_reviews_dirty());
|
||||
editor.execute("diff.review.edit 1");
|
||||
editor.execute("review.edit 1");
|
||||
// Native undo applies to comment text, never to the reviewed source.
|
||||
type_text(&mut editor, "A!");
|
||||
editor.handle(Input::Escape);
|
||||
@@ -3739,11 +3796,11 @@ fn local_review_native_composition_roundtrip_and_draft_lifecycle() {
|
||||
vec!["before".into(), "removed".into()],
|
||||
vec!["after".into(), "added".into()],
|
||||
);
|
||||
editor.execute(&format!("diff.review.open {}", path.display()));
|
||||
editor.execute(&format!("review.open {}", path.display()));
|
||||
assert!(editor.context().text.contains("Explain the removed branch"));
|
||||
editor.execute("diff.review.next");
|
||||
editor.execute("review.next");
|
||||
assert_eq!(editor.context().text, "Explain the removed branch");
|
||||
editor.execute("diff.review.delete");
|
||||
editor.execute("review.delete");
|
||||
assert!(editor.context().text.starts_with("# Local review"));
|
||||
editor.execute("w");
|
||||
assert!(Review::load(&path, &root).unwrap().comments.is_empty());
|
||||
@@ -3770,34 +3827,34 @@ fn review_identity_changes_retire_drafts_before_reusing_comment_ids() {
|
||||
editor.set_review_directory(root.join("data"));
|
||||
}
|
||||
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
|
||||
editor.execute("diff.comment");
|
||||
editor.execute("comment");
|
||||
type_text(&mut editor, "iOriginal feedback");
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute("diff.review.summary");
|
||||
editor.execute("review.summary");
|
||||
type_text(&mut editor, "iKeep summary");
|
||||
editor.handle(Input::Escape);
|
||||
let original = root.join("original.json");
|
||||
editor.execute(&format!("diff.review.save {}", original.display()));
|
||||
editor.execute(&format!("review.save {}", original.display()));
|
||||
|
||||
editor.execute("diff.review.delete 1");
|
||||
editor.execute("diff.comment");
|
||||
editor.execute("review.delete 1");
|
||||
editor.execute("comment");
|
||||
assert_eq!(editor.context().text, "");
|
||||
type_text(&mut editor, "iFresh feedback");
|
||||
editor.handle(Input::Escape);
|
||||
let fresh = root.join("fresh.json");
|
||||
editor.execute(&format!("diff.review.save {}", fresh.display()));
|
||||
editor.execute(&format!("review.save {}", fresh.display()));
|
||||
let document = crate::review::Review::load(&fresh, &root).unwrap();
|
||||
assert_eq!(document.comments[0].id, 1);
|
||||
assert_eq!(document.comments[0].body, "Fresh feedback");
|
||||
assert_eq!(document.summary, "Keep summary");
|
||||
|
||||
editor.execute(&format!("diff.review.open {}", original.display()));
|
||||
editor.execute("diff.review.edit 1");
|
||||
editor.execute(&format!("review.open {}", original.display()));
|
||||
editor.execute("review.edit 1");
|
||||
assert_eq!(editor.context().text, "Original feedback");
|
||||
editor.execute("diff.review.summary");
|
||||
editor.execute("review.summary");
|
||||
assert_eq!(editor.context().text, "Keep summary");
|
||||
let restored = root.join("restored.json");
|
||||
editor.execute(&format!("diff.review.save {}", restored.display()));
|
||||
editor.execute(&format!("review.save {}", restored.display()));
|
||||
assert_eq!(
|
||||
crate::review::Review::load(&restored, &root)
|
||||
.unwrap()
|
||||
@@ -3806,13 +3863,13 @@ fn review_identity_changes_retire_drafts_before_reusing_comment_ids() {
|
||||
"Original feedback"
|
||||
);
|
||||
|
||||
editor.execute("diff.review.new");
|
||||
editor.execute("diff.comment");
|
||||
editor.execute("review.new");
|
||||
editor.execute("comment");
|
||||
assert_eq!(editor.context().text, "");
|
||||
type_text(&mut editor, "iSeparate review");
|
||||
editor.handle(Input::Escape);
|
||||
let separate = root.join("separate.json");
|
||||
editor.execute(&format!("diff.review.save {}", separate.display()));
|
||||
editor.execute(&format!("review.save {}", separate.display()));
|
||||
let document = crate::review::Review::load(&separate, &root).unwrap();
|
||||
assert_eq!(document.comments[0].body, "Separate review");
|
||||
assert_eq!(document.summary, "");
|
||||
@@ -3836,14 +3893,14 @@ fn precise_review_ranges_capture_graphemes_and_navigate_to_their_start() {
|
||||
editor.bind_diff_buffer();
|
||||
let owner = editor.pane_id();
|
||||
type_text(&mut editor, keys);
|
||||
type_text(&mut editor, ":diff.comment");
|
||||
type_text(&mut editor, ":comment");
|
||||
editor.handle(Input::Enter);
|
||||
editor.execute(&format!(
|
||||
"diff.review.save {}",
|
||||
"review.save {}",
|
||||
root.join("range.json").display()
|
||||
));
|
||||
let diff = editor.workspace_diff().unwrap();
|
||||
let anchor = &diff.local_review.document.comments[0].anchor;
|
||||
let anchor = &editor.local_review.document.comments[0].anchor;
|
||||
let (start, end) = if keys == "llvj" {
|
||||
(
|
||||
TextPosition { row: 0, column: 2 },
|
||||
@@ -3857,7 +3914,10 @@ fn precise_review_ranges_capture_graphemes_and_navigate_to_their_start() {
|
||||
};
|
||||
assert_eq!(anchor.start, start);
|
||||
assert_eq!(anchor.end, end);
|
||||
assert!(diff.review_anchor_range(anchor).is_some());
|
||||
assert!(
|
||||
diff.review_anchor_range(anchor, &editor.local_review)
|
||||
.is_some()
|
||||
);
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.pane_id(), owner);
|
||||
assert_eq!(
|
||||
@@ -3876,18 +3936,12 @@ fn precise_review_ranges_capture_graphemes_and_navigate_to_their_start() {
|
||||
historical_diff(&mut editor, &root, vec!["before".into()], split_lines(text));
|
||||
editor.diff.as_mut().unwrap().focus = Focus::After;
|
||||
editor.bind_diff_buffer();
|
||||
editor.execute("diff.comment");
|
||||
editor.execute("comment");
|
||||
editor.execute(&format!(
|
||||
"diff.review.save {}",
|
||||
"review.save {}",
|
||||
root.join("range.json").display()
|
||||
));
|
||||
let anchor = &editor
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.comments[0]
|
||||
.anchor;
|
||||
let anchor = &editor.local_review.document.comments[0].anchor;
|
||||
assert_eq!(anchor.start, TextPosition { row: 0, column: 0 });
|
||||
assert_eq!(
|
||||
anchor.end,
|
||||
@@ -3965,17 +4019,17 @@ fn local_review_visual_commands_and_palette_preserve_both_side_ranges() {
|
||||
type_text(&mut editor, "Vj");
|
||||
if palette {
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
type_text(&mut editor, "diff.comment");
|
||||
type_text(&mut editor, "comment");
|
||||
} else {
|
||||
type_text(&mut editor, ":diff.comment");
|
||||
type_text(&mut editor, ":comment");
|
||||
}
|
||||
editor.handle(Input::Enter);
|
||||
editor.execute(&format!(
|
||||
"diff.review.save {}",
|
||||
"review.save {}",
|
||||
root.join("range.json").display()
|
||||
));
|
||||
let diff = editor.workspace_diff().unwrap();
|
||||
let anchor = diff.local_review.document.comments[0].anchor.clone();
|
||||
let anchor = editor.local_review.document.comments[0].anchor.clone();
|
||||
assert_eq!(anchor.start, TextPosition { row: 0, column: 0 });
|
||||
assert_eq!(anchor.end, TextPosition { row: 2, column: 0 });
|
||||
assert_eq!(
|
||||
@@ -3986,7 +4040,10 @@ fn local_review_visual_commands_and_palette_preserve_both_side_ranges() {
|
||||
Side::After
|
||||
}
|
||||
);
|
||||
assert!(diff.review_anchor_range(&anchor).is_some());
|
||||
assert!(
|
||||
diff.review_anchor_range(&anchor, &editor.local_review)
|
||||
.is_some()
|
||||
);
|
||||
let source_pane = editor.diff_owner.unwrap();
|
||||
editor.select_pane(source_pane);
|
||||
editor
|
||||
@@ -3997,19 +4054,10 @@ fn local_review_visual_commands_and_palette_preserve_both_side_ranges() {
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.revisions[anchor.side.index()] = "changed-snapshot".into();
|
||||
editor.execute("diff.review.overview");
|
||||
editor.execute("review.overview");
|
||||
assert!(editor.context().text.contains("stale"));
|
||||
editor.execute("diff.review.edit 1");
|
||||
assert_eq!(
|
||||
editor
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.comments[0]
|
||||
.anchor,
|
||||
anchor
|
||||
);
|
||||
editor.execute("review.edit 1");
|
||||
assert_eq!(editor.local_review.document.comments[0].anchor, anchor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4019,10 +4067,10 @@ fn local_review_guards_metadata_and_hidden_native_drafts_against_loss() {
|
||||
let root = directory.0.canonicalize().unwrap();
|
||||
let mut editor = editor();
|
||||
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
|
||||
editor.execute("diff.comment");
|
||||
editor.execute("comment");
|
||||
type_text(&mut editor, "iUnsaved feedback");
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute("diff.review.overview");
|
||||
editor.execute("review.overview");
|
||||
let panel = editor.pane_id();
|
||||
let source_buffer = editor.diff_return_buffer.unwrap();
|
||||
editor.delete_buffer(source_buffer, false);
|
||||
@@ -4043,17 +4091,9 @@ fn local_review_guards_metadata_and_hidden_native_drafts_against_loss() {
|
||||
editor.execute("q");
|
||||
assert!(!editor.should_quit());
|
||||
let invalid = root.join("missing/review.json");
|
||||
editor.execute(&format!("diff.review.save {}", invalid.display()));
|
||||
editor.execute(&format!("review.save {}", invalid.display()));
|
||||
assert!(editor.local_reviews_dirty());
|
||||
assert!(
|
||||
editor
|
||||
.workspace_diff()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.comments
|
||||
.is_empty()
|
||||
);
|
||||
assert!(editor.local_review.document.comments.is_empty());
|
||||
assert!(
|
||||
editor
|
||||
.parked
|
||||
@@ -4061,10 +4101,10 @@ fn local_review_guards_metadata_and_hidden_native_drafts_against_loss() {
|
||||
.any(|buffer| buffer.lines == ["Unsaved feedback"])
|
||||
);
|
||||
let path = root.join("review.json");
|
||||
editor.execute(&format!("diff.review.save {}", path.display()));
|
||||
editor.execute(&format!("review.save {}", path.display()));
|
||||
assert!(!editor.local_reviews_dirty());
|
||||
fs::write(&path, "external change").unwrap();
|
||||
editor.execute("diff.review.verdict looks_good");
|
||||
editor.execute("review.verdict looks_good");
|
||||
editor.execute("w");
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "external change");
|
||||
assert!(editor.local_reviews_dirty());
|
||||
@@ -5443,7 +5483,7 @@ fn diff_review_actions_preserve_readonly_text_history_and_parked_tabs() {
|
||||
assert_eq!(editor.tab_id(), first_tab);
|
||||
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0]));
|
||||
editor.execute(&format!(
|
||||
"diff.review.save {}",
|
||||
"review.save {}",
|
||||
directory.path("review.json").display()
|
||||
));
|
||||
editor.execute("diff.close");
|
||||
@@ -7596,7 +7636,7 @@ mod system_clipboard {
|
||||
editor.set_clipboard_mode(mode).unwrap();
|
||||
editor.clipboard = vec!["internal".into()];
|
||||
fixture.external("external");
|
||||
editor.execute("diff.review.copy");
|
||||
editor.execute("review.copy");
|
||||
assert_eq!(editor.clipboard, ["internal"]);
|
||||
assert_eq!(fixture.bytes("state"), b"external");
|
||||
|
||||
@@ -7607,11 +7647,11 @@ mod system_clipboard {
|
||||
vec!["xe\u{301}👩💻".into(), "界z".into()],
|
||||
vec!["new".into()],
|
||||
);
|
||||
editor.execute("diff.review.edit 1");
|
||||
editor.execute("review.edit 1");
|
||||
type_text(&mut editor, "A UNSAVED");
|
||||
editor.handle(Input::Escape);
|
||||
let draft = editor.context().text;
|
||||
editor.execute("diff.review.copy");
|
||||
editor.execute("review.copy");
|
||||
assert_eq!(editor.context().text, draft);
|
||||
assert!(editor.local_reviews_dirty());
|
||||
let stored = store.load(&root, id).unwrap();
|
||||
@@ -7622,7 +7662,7 @@ mod system_clipboard {
|
||||
assert_eq!(editor.clipboard, ["internal"]);
|
||||
let owned = editor.owned_system_clipboard.clone();
|
||||
fixture.fail("fail-write", true);
|
||||
editor.execute("diff.review.copy");
|
||||
editor.execute("review.copy");
|
||||
assert_eq!(fixture.bytes("state"), expected.as_bytes());
|
||||
assert_eq!(editor.owned_system_clipboard, owned);
|
||||
} else {
|
||||
@@ -7631,18 +7671,11 @@ mod system_clipboard {
|
||||
assert_eq!(fixture.bytes("state"), b"external");
|
||||
assert!(fixture.bytes("writes").is_empty());
|
||||
}
|
||||
editor.execute("diff.review.copy unexpected");
|
||||
editor.execute("review.copy unexpected");
|
||||
editor.with_pane(editor.diff_owner.unwrap(), |editor| {
|
||||
editor
|
||||
.diff
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.local_review
|
||||
.document
|
||||
.comments
|
||||
.clear();
|
||||
editor.local_review.document.comments.clear();
|
||||
});
|
||||
editor.execute("diff.review.copy");
|
||||
editor.execute("review.copy");
|
||||
if mode == "system" {
|
||||
assert_eq!(fixture.bytes("state"), expected.as_bytes());
|
||||
} else {
|
||||
|
||||
+127
-38
@@ -2951,6 +2951,62 @@ fn diff_status_layouts_hide_absent_panes_and_leave_unchanged_renames_blank() {
|
||||
assert!(text.contains("After · M"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_review_markers_highlight_exact_text_and_reject_stale_ranges() {
|
||||
use crate::editor::Effect;
|
||||
use crate::plugin::TextPosition;
|
||||
use crate::review::{Anchor, Comment, Review, Side};
|
||||
for wrapped in [false, true] {
|
||||
let directory = Directory::new();
|
||||
let text = format!("aé界{}\nsecond\n", " trailing".repeat(10));
|
||||
let path = directory.file("source.txt", &text);
|
||||
let mut editor = Editor::open(Some(path), vec!["text".into()]).unwrap();
|
||||
editor.local_review.document = Review::new(directory.0.canonicalize().unwrap());
|
||||
editor.local_review.document.comments.push(Comment {
|
||||
id: 1,
|
||||
anchor: Anchor {
|
||||
revision: "working:source-snapshot".into(),
|
||||
path: "source.txt".into(),
|
||||
side: Side::After,
|
||||
start: TextPosition { row: 0, column: 1 },
|
||||
end: TextPosition { row: 0, column: 3 },
|
||||
content: text,
|
||||
},
|
||||
body: "Feedback".into(),
|
||||
resolved: false,
|
||||
});
|
||||
editor.local_review.selected = Some(1);
|
||||
if wrapped {
|
||||
editor.execute("word_wrap.toggle");
|
||||
}
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 16)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains('●'));
|
||||
assert!(
|
||||
terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content
|
||||
.iter()
|
||||
.filter(|cell| cell.bg == Color::Magenta)
|
||||
.count()
|
||||
>= 2
|
||||
);
|
||||
let anchor = editor.local_review.document.comments[0].anchor.clone();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("changed entirely\n".into())])
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
assert!(!screen_text(&terminal).contains('●'));
|
||||
assert_eq!(editor.local_review.document.comments[0].anchor, anchor);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_diff(
|
||||
terminal: &mut Terminal<ratatui::backend::TestBackend>,
|
||||
diff: &DiffState,
|
||||
@@ -2959,12 +3015,44 @@ fn render_diff(
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
panels.begin_frame();
|
||||
draw_diff(frame, diff, frame.area(), "↑/↓", "h/j/k/l", panels, None);
|
||||
draw_diff(
|
||||
frame,
|
||||
diff,
|
||||
frame.area(),
|
||||
"↑/↓",
|
||||
"h/j/k/l",
|
||||
panels,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn commented_diff() -> DiffState {
|
||||
fn render_review_diff(
|
||||
terminal: &mut Terminal<ratatui::backend::TestBackend>,
|
||||
diff: &DiffState,
|
||||
review: &crate::review::ReviewState,
|
||||
panels: &mut WheelPanels,
|
||||
) {
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
panels.begin_frame();
|
||||
draw_diff(
|
||||
frame,
|
||||
diff,
|
||||
frame.area(),
|
||||
"↑/↓",
|
||||
"h/j/k/l",
|
||||
panels,
|
||||
None,
|
||||
Some(review),
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn commented_diff() -> (DiffState, crate::review::ReviewState) {
|
||||
use crate::review::{Anchor, Comment, Side};
|
||||
let before = format!(
|
||||
"{}\nold e\u{301}界 second\nkeep",
|
||||
@@ -2980,8 +3068,9 @@ fn commented_diff() -> DiffState {
|
||||
file.old_path = Some("old.txt".into());
|
||||
let mut diff = diff_comparison(vec![file]);
|
||||
diff.files_visible = false;
|
||||
diff.local_review.document.root = diff.comparison.as_ref().unwrap().root.clone();
|
||||
diff.local_review.document.comments = vec![
|
||||
let mut review = crate::review::ReviewState::default();
|
||||
review.document.root = diff.comparison.as_ref().unwrap().root.clone();
|
||||
review.document.comments = vec![
|
||||
Comment {
|
||||
id: 1,
|
||||
anchor: Anchor {
|
||||
@@ -3009,8 +3098,8 @@ fn commented_diff() -> DiffState {
|
||||
resolved: false,
|
||||
},
|
||||
];
|
||||
diff.local_review.selected = Some(1);
|
||||
diff
|
||||
review.selected = Some(1);
|
||||
(diff, review)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3022,14 +3111,13 @@ fn local_review_highlights_exact_graphemes_newlines_clips_and_wrap_continuations
|
||||
for side in [Side::Before, Side::After] {
|
||||
for (wrapping, horizontal) in [(false, 0), (false, 6), (true, 0)] {
|
||||
for (start, end) in [((0, 4), (0, 6)), ((0, 5), (1, 5)), ((0, 84), (1, 0))] {
|
||||
let mut diff = commented_diff();
|
||||
let (mut diff, mut review) = commented_diff();
|
||||
diff.set_view_mode(mode);
|
||||
diff.top = 0;
|
||||
diff.word_wrap = wrapping;
|
||||
diff.left_before = horizontal;
|
||||
diff.left_after = horizontal;
|
||||
let comment = diff
|
||||
.local_review
|
||||
let comment = review
|
||||
.document
|
||||
.comments
|
||||
.iter_mut()
|
||||
@@ -3044,11 +3132,11 @@ fn local_review_highlights_exact_graphemes_newlines_clips_and_wrap_continuations
|
||||
column: end.1,
|
||||
};
|
||||
let anchor = comment.anchor.clone();
|
||||
diff.local_review.selected = Some(comment.id);
|
||||
review.selected = Some(comment.id);
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(90, 40)).unwrap();
|
||||
let mut panels = WheelPanels::default();
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
render_review_diff(&mut terminal, &diff, &review, &mut panels);
|
||||
let file = &diff.comparison.as_ref().unwrap().files[0];
|
||||
let wrapped = panels
|
||||
.diff_wrap
|
||||
@@ -3164,13 +3252,13 @@ fn local_review_highlights_exact_graphemes_newlines_clips_and_wrap_continuations
|
||||
fn local_review_markers_preserve_diff_signs_ranges_and_wrapped_geometry() {
|
||||
for mode in [ViewMode::Split, ViewMode::Unified] {
|
||||
for wrapping in [false, true] {
|
||||
let mut diff = commented_diff();
|
||||
let (mut diff, review) = commented_diff();
|
||||
diff.set_view_mode(mode);
|
||||
diff.top = 0;
|
||||
diff.word_wrap = wrapping;
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 40)).unwrap();
|
||||
let mut panels = WheelPanels::default();
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
render_review_diff(&mut terminal, &diff, &review, &mut panels);
|
||||
let buffer = terminal.backend().buffer();
|
||||
let markers: Vec<_> = buffer
|
||||
.content
|
||||
@@ -3219,20 +3307,20 @@ fn local_review_markers_preserve_diff_signs_ranges_and_wrapped_geometry() {
|
||||
fn local_review_markers_reject_stale_identity_text_and_rename_paths() {
|
||||
for mode in [ViewMode::Split, ViewMode::Unified] {
|
||||
for mismatch in ["revision", "content", "path", "side", "root"] {
|
||||
let mut diff = commented_diff();
|
||||
let (mut diff, mut review) = commented_diff();
|
||||
diff.set_view_mode(mode);
|
||||
diff.local_review.document.comments.truncate(1);
|
||||
let anchor = &mut diff.local_review.document.comments[0].anchor;
|
||||
review.document.comments.truncate(1);
|
||||
let anchor = &mut review.document.comments[0].anchor;
|
||||
match mismatch {
|
||||
"revision" => anchor.revision.push_str("-newer"),
|
||||
"content" => anchor.content.replace_range(..1, "X"),
|
||||
"path" => anchor.path = "new.txt".into(),
|
||||
"side" => anchor.side = crate::review::Side::After,
|
||||
"root" => diff.local_review.document.root = "another-repository".into(),
|
||||
"root" => review.document.root = "another-repository".into(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 20)).unwrap();
|
||||
render_diff(&mut terminal, &diff, &mut WheelPanels::default());
|
||||
render_review_diff(&mut terminal, &diff, &review, &mut WheelPanels::default());
|
||||
assert!(
|
||||
terminal
|
||||
.backend()
|
||||
@@ -3258,18 +3346,18 @@ fn local_review_markers_reject_stale_identity_text_and_rename_paths() {
|
||||
#[test]
|
||||
fn unified_context_shows_comments_from_either_snapshot_side() {
|
||||
for side in [crate::review::Side::Before, crate::review::Side::After] {
|
||||
let mut diff = commented_diff();
|
||||
let (mut diff, mut review) = commented_diff();
|
||||
diff.set_view_mode(ViewMode::Unified);
|
||||
diff.local_review
|
||||
review
|
||||
.document
|
||||
.comments
|
||||
.retain(|comment| comment.anchor.side == side);
|
||||
let comment = &mut diff.local_review.document.comments[0];
|
||||
let comment = &mut review.document.comments[0];
|
||||
comment.anchor.start = crate::plugin::TextPosition { row: 2, column: 1 };
|
||||
comment.anchor.end = crate::plugin::TextPosition { row: 2, column: 3 };
|
||||
diff.local_review.selected = Some(comment.id);
|
||||
review.selected = Some(comment.id);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 20)).unwrap();
|
||||
render_diff(&mut terminal, &diff, &mut WheelPanels::default());
|
||||
render_review_diff(&mut terminal, &diff, &review, &mut WheelPanels::default());
|
||||
assert!(
|
||||
screen_text(&terminal).contains("●keep"),
|
||||
"{side:?}: {}",
|
||||
@@ -3296,13 +3384,13 @@ fn unified_context_shows_comments_from_either_snapshot_side() {
|
||||
fn local_review_markers_yield_to_text_on_narrow_surfaces() {
|
||||
for mode in [ViewMode::Split, ViewMode::Unified] {
|
||||
for (width, height) in [(1, 1), (2, 2), (3, 3), (8, 4), (16, 8), (30, 12)] {
|
||||
let mut diff = commented_diff();
|
||||
let (mut diff, review) = commented_diff();
|
||||
diff.set_view_mode(mode);
|
||||
diff.word_wrap = true;
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||
let mut panels = WheelPanels::default();
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
render_review_diff(&mut terminal, &diff, &review, &mut panels);
|
||||
for (area, focus) in panels
|
||||
.diff
|
||||
.as_ref()
|
||||
@@ -3702,6 +3790,7 @@ fn pull_request_review_symbols_use_terminal_cells_and_keep_the_selection_arrow_i
|
||||
"h/j/k/l",
|
||||
&mut panels,
|
||||
with_editor.then_some(&editor),
|
||||
None,
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
@@ -4834,7 +4923,7 @@ fn unavailable_file_documents_show_the_actual_error_in_managed_workspaces() {
|
||||
};
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
panels.draw_documents(frame, &diff, frame.area(), None, None);
|
||||
panels.draw_documents(frame, &diff, frame.area(), None, None, None);
|
||||
})
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
@@ -8452,7 +8541,7 @@ fn command_input_keeps_characterwise_and_linewise_selection_highlights() {
|
||||
editor.handle(Input::Right);
|
||||
let ranges = editor.selections();
|
||||
editor.handle(Input::Char(':'));
|
||||
for ch in "diff.comment".chars() {
|
||||
for ch in "comment".chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
terminal
|
||||
@@ -8489,10 +8578,10 @@ fn native_visual_selection_overrides_selected_review_range_background() {
|
||||
editor.execute("diff.view.toggle");
|
||||
}
|
||||
editor.handle(Input::Char('\t'));
|
||||
editor.execute("diff.comment");
|
||||
editor.execute("comment");
|
||||
let directory = Directory::new();
|
||||
editor.execute(&format!(
|
||||
"diff.review.save {}",
|
||||
"review.save {}",
|
||||
directory.0.join("review.json").display()
|
||||
));
|
||||
editor.handle(Input::Escape);
|
||||
@@ -8537,7 +8626,7 @@ fn review_editor_floats_over_the_diff_without_changing_workspace_layout() {
|
||||
.find(|(pane, _)| *pane == owner)
|
||||
.unwrap()
|
||||
.1;
|
||||
editor.execute("diff.comment");
|
||||
editor.execute("comment");
|
||||
let panel = editor.review_panel().expect("focused floating review");
|
||||
assert_eq!(editor.pane_layout().len(), 1);
|
||||
assert!(!editor.pane_layout().contains(panel));
|
||||
@@ -8568,14 +8657,14 @@ fn review_editor_floats_over_the_diff_without_changing_workspace_layout() {
|
||||
assert_eq!(editor.review_panel(), Some(panel));
|
||||
let directory = Directory::new();
|
||||
editor.execute(&format!(
|
||||
"diff.review.save {}",
|
||||
"review.save {}",
|
||||
directory.0.join("review.json").display()
|
||||
));
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.review_panel().is_none());
|
||||
assert_eq!(editor.pane_id(), owner);
|
||||
assert_eq!(editor.context().text, source_text);
|
||||
editor.execute("diff.review.edit 1");
|
||||
editor.execute("review.edit 1");
|
||||
assert_eq!(
|
||||
editor.context().text,
|
||||
"Retained draft",
|
||||
@@ -8585,7 +8674,7 @@ fn review_editor_floats_over_the_diff_without_changing_workspace_layout() {
|
||||
assert_eq!(editor.review_panel(), Some(panel));
|
||||
editor.execute("tab.new");
|
||||
editor.execute("tab.previous");
|
||||
editor.execute("diff.review.edit 1");
|
||||
editor.execute("review.edit 1");
|
||||
assert_eq!(editor.review_panel(), Some(panel));
|
||||
assert_eq!(editor.context().text, "Retained draft");
|
||||
editor.execute("pane.close");
|
||||
@@ -8605,7 +8694,7 @@ fn review_prose_wraps_by_default_without_changing_text_or_source_wrapping() {
|
||||
let text = format!("{}wrapped_tail", "prose ".repeat(20));
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 30)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
for command in ["diff.comment", "diff.review.summary"] {
|
||||
for command in ["comment", "review.summary"] {
|
||||
editor.execute(command);
|
||||
editor.handle(Input::Char('i'));
|
||||
editor.handle(Input::Paste(text.clone()));
|
||||
@@ -8627,20 +8716,20 @@ fn review_prose_wraps_by_default_without_changing_text_or_source_wrapping() {
|
||||
assert_eq!(editor.context().text, text);
|
||||
let directory = Directory::new();
|
||||
editor.execute(&format!(
|
||||
"diff.review.save {}",
|
||||
"review.save {}",
|
||||
directory.0.join("review.json").display()
|
||||
));
|
||||
editor.handle(Input::Escape);
|
||||
assert!(!editor.word_wrap());
|
||||
}
|
||||
editor.execute("diff.review.overview");
|
||||
editor.execute("review.overview");
|
||||
assert!(editor.word_wrap());
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute("diff.review.edit 1");
|
||||
editor.execute("review.edit 1");
|
||||
editor.execute("word_wrap.toggle");
|
||||
assert!(!editor.word_wrap());
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute("diff.review.edit 1");
|
||||
editor.execute("review.edit 1");
|
||||
assert!(
|
||||
!editor.word_wrap(),
|
||||
"reopening must retain the user's toggle"
|
||||
|
||||
Reference in New Issue
Block a user