feat: scope local reviews to comparisons

This commit is contained in:
2026-10-25 01:00:58 +02:00
parent 815fcccbe8
commit 448c3bf477
15 changed files with 760 additions and 72 deletions
+4
View File
@@ -26,6 +26,10 @@ vamp --review resolve 1 3 --repo . # Mark comment 3 resolved, preserving its fee
The review CLI discovers the Git/Jujutsu checkout from `--repo` (default: current directory). It uses the same default configuration, `--config PATH`, `--no-config`, and `review_directory` as the editor, but starts no editor workers or terminal UI. Missing storage lists no reviews without creating a database; incompatible storage is rejected and preserved. The review CLI discovers the Git/Jujutsu checkout from `--repo` (default: current directory). It uses the same default configuration, `--config PATH`, `--no-config`, and `review_directory` as the editor, but starts no editor workers or terminal UI. Missing storage lists no reviews without creating a database; incompatible storage is rejected and preserved.
Local reviews are scoped to the canonical checkout and ordered, resolved Before/After pair. Opening a different comparison selects its own review; reopening the same pair resumes its feedback and progress. In-place Working refreshes retain the active review. Ordinary source-buffer comments use separate source reviews. CLI listings identify each review's scope; JSON exposes it as `comparison` (an endpoint pair, or `null` for source reviews).
Review storage now uses SQLite schema 4 and JSON export version 5. Older formats are rejected and preserved without migration or reset.
Use the persistent **Review ID** printed by `list`, not a list position. IDs are shared across repositories; listing filters them to the selected checkout, so its first review need not be ID 1. Human-readable listings include a command to show each review. The internal save revision is omitted from text but remains in JSON; it is not a Git/Jujutsu revision. `show all` is not supported: use `show ID --all` to include resolved comments in that review. Use the persistent **Review ID** printed by `list`, not a list position. IDs are shared across repositories; listing filters them to the selected checkout, so its first review need not be ID 1. Human-readable listings include a command to show each review. The internal save revision is omitted from text but remains in JSON; it is not a Git/Jujutsu revision. `show all` is not supported: use `show ID --all` to include resolved comments in that review.
`show` includes unresolved comments by default; `--all` includes resolved comments too. Human-readable output is the default, with source excerpts in language-tagged Markdown code blocks. JSON includes the canonical repository root, storage review ID/revision, summary/verdict, total/resolved/unresolved counts, and comments with exact zero-based, end-exclusive grapheme anchors and raw selected source excerpts—not whole source files or Markdown fences. `list --json` returns the repository and review metadata/counts without comments. Explicit editor JSON exports still contain complete anchor source text. `show` includes unresolved comments by default; `--all` includes resolved comments too. Human-readable output is the default, with source excerpts in language-tagged Markdown code blocks. JSON includes the canonical repository root, storage review ID/revision, summary/verdict, total/resolved/unresolved counts, and comments with exact zero-based, end-exclusive grapheme anchors and raw selected source excerpts—not whole source files or Markdown fences. `list --json` returns the repository and review metadata/counts without comments. Explicit editor JSON exports still contain complete anchor source text.
+3 -3
View File
@@ -17,10 +17,10 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
Virtual diff leaves retain their original catalog input index for caller-owned annotations. Normal scanned leaves accept optional repository status and old-path metadata without filtering unchanged files; missing tracked deletions add virtual leaves and required directories. Metadata-only updates preserve query, selection, expansion, and view without rebuilding the catalog. Virtual diff leaves retain their original catalog input index for caller-owned annotations. Normal scanned leaves accept optional repository status and old-path metadata without filtering unchanged files; missing tracked deletions add virtual leaves and required directories. Metadata-only updates preserve query, selection, expansion, and view without rebuilding the catalog.
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies. - `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
- `repository`: discovers jj/Git repositories and local/advertised remote revisions, builds disk snapshots and changed-file hunks, and owns isolated temporary comparison repositories with bounded, cancellable subprocesses. It never saves editor buffers or modifies active refs/checkouts. - `repository`: discovers jj/Git repositories and local/advertised remote revisions, builds disk snapshots and changed-file hunks, and owns isolated temporary comparison repositories with bounded, cancellable subprocesses. It never saves editor buffers or modifies active refs/checkouts.
- `review`: owns 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. - `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 4 stores review identity, repository, revision, summary, verdict, and nullable ordered comparison endpoints in `reviews`; null endpoints identify source 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. `Store` retains compare-and-swap review revisions and atomic whole-review saves: metadata and replacement comment/checkmark rows commit together. A stored review cannot be reassigned to another scope. 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 5 is an independent import/export format. Incompatible databases, including schema 3, are rejected and preserved without migration or reset. CLI metadata and selectors reuse loaded reviews; selector choices live only in the displayed buffer.
The review CLI keeps `list`/`show` read-only. Comment deletion and resolution validate 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. Resolution preserves comment bodies and immutable anchors; already-resolved comments succeed without writing or advancing the revision. The review CLI keeps `list`/`show` read-only. Comment deletion and resolution validate 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. Resolution preserves comment bodies and immutable anchors; already-resolved comments succeed without writing or advancing the revision.
`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. `editor_review` owns tab-local review state selected by canonical repository root and ordered resolved comparison identities, or a separate source scope for ordinary repository buffers. A sole scope-matching review restores automatically; multiple matches open the floating selector. `diff` freezes initial review endpoints through in-place Working refreshes; explicit comparisons and moved bookmark endpoints resolve their own scope. Source and comparison panes share the comment editor, immutable anchors, storage, and review commands, not their review documents. Dirty drafts block scope changes, and stored/JSON opens reject mismatched scopes before replacing state. 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. 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. 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. 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. - `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
+2 -2
View File
@@ -98,13 +98,13 @@ 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. 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. 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. Review selection separates source and comparison scopes even within one tab; dirty drafts guard transitions, and mismatched stored/JSON opens cannot reassign feedback. Review state remains tab-owned, with the selected document scoped to its source or comparison.
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. 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.
Review text-format checks cover language-tagged excerpts, Honey's `hon` fence alias, special filenames and frozen shebang detection, unknown languages, Unicode, trailing-newline preservation, and nested backtick fences. Live PTY/CLI smoke imports `.hon` and nested Markdown comments, copies/pastes them into a saved buffer, compares its exact bytes with `show --all`, and verifies unresolved filtering, raw JSON excerpts, unchanged source bytes and an unchanged stored review revision. Review text-format checks cover language-tagged excerpts, Honey's `hon` fence alias, special filenames and frozen shebang detection, unknown languages, Unicode, trailing-newline preservation, and nested backtick fences. Live PTY/CLI smoke imports `.hon` and nested Markdown comments, copies/pastes them into a saved buffer, compares its exact bytes with `show --all`, and verifies unresolved filtering, raw JSON excerpts, unchanged source bytes and an unchanged stored review revision.
SQLite review checks cover exact anchors across restarts and revision changes, repository isolation, independent connections and optimistic conflicts, locked/unwritable stores, incompatible schema preservation, relational constraints, full unsigned comment IDs, stable comment order, bounded/corrupt rows, rollback after partial comment insertion, private file permissions, and XDG/config path resolution. Schema 1 document storage is rejected without modification. Editor checks require explicit writes for comment/summary text, discard unsaved text on dismissal without retaining discarded undo history, prevent metadata autosaves from committing hidden drafts, persist verdicts/deletions, restore a sole review, choose among multiple reviews, and keep exports independent from autosaves. Test fixtures use isolated review directories. Live PTY smoke verifies that Escape leaves no new stored comment, `:w` persists exact text, and pane close discards a later edit; reopening and writing again preserves the saved body. SQLite review checks cover exact anchors, ordered comparison-scope round-trips and immutable scope saves, 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. SQLite schemas through 3 and JSON versions through 4 are rejected without modification. Editor checks cover automatic selection for different endpoint pairs, restoration of the same pair, stable in-place Working refreshes, moved-bookmark scope changes, separate source feedback, per-scope selectors, and explicit draft-save/discard behavior. Live PTY/CLI smoke saves feedback on two immutable pairs, reopens the first to restore only its feedback, retains Working feedback through refresh, saves a separate source comment, and inspects all scopes through SQLite and CLI JSON. Fixtures use isolated repositories and review directories; no forge writes.
Manual-resolution checks cover immediate persistence, reopen/export/import, independent stale status, conflict-retained drafts, and incompatible version preservation. CLI checks cover filename-safe dispatch, Unicode excerpts, unresolved filtering/counts, absent storage, read-only access and permissions, symlink rejection, and incompatible schema preservation. Manual-resolution checks cover immediate persistence, reopen/export/import, independent stale status, conflict-retained drafts, and incompatible version preservation. CLI checks cover filename-safe dispatch, Unicode excerpts, unresolved filtering/counts, absent storage, read-only access and permissions, symlink rejection, and incompatible schema preservation.
Live PTY/CLI smoke composes and explicitly saves a comment, resolves it in the editor, observes an empty default CLI comment list with explicit counts, retrieves the resolved comment and exact excerpt with `--all`, and unresolves it. Each transition advances the same stored review revision; listing absent storage creates no data directory. Live PTY/CLI smoke composes and explicitly saves a comment, resolves it in the editor, observes an empty default CLI comment list with explicit counts, retrieves the resolved comment and exact excerpt with `--all`, and unresolves it. Each transition advances the same stored review revision; listing absent storage creates no data directory.
+7 -5
View File
@@ -504,8 +504,8 @@ Local review buffers (comments, summaries, overview, and stored-review selector)
| `:review.verdict looks_good` | Record **Looks good** | | `:review.verdict looks_good` | Record **Looks good** |
| `:review.verdict changes_requested` | Record **Changes requested**; `none` clears the verdict | | `: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.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.open [id or path]` | Show reviews for the current scope, open a matching stored review ID, or import matching JSON as a new review |
| `:review.new` | Create a separate stored review for the current repository | | `:review.new` | Create another stored review for the current comparison or ordinary-source scope |
File checkmarks remain diff-specific (`:diff.review.toggle`). File checkmarks remain diff-specific (`:diff.review.toggle`).
@@ -519,13 +519,15 @@ From the shell, use `vamp --review delete REVIEW_ID COMMENT_ID [--repo PATH] [--
Use `vamp --review resolve REVIEW_ID COMMENT_ID [--repo PATH] [--json]` after addressing and verifying feedback to mark it resolved without changing its body or original anchor. The command prints the updated review's remaining unresolved comments and full counts; `show REVIEW_ID --all` still includes the resolved original. Repeating resolution succeeds without a write or revision bump. Both IDs must be positive and refer to the selected repository; invalid targets and absent storage fail without creating or changing stored data. Like deletion, resolution rejects stale saves from an editor holding the previous review revision. Use `vamp --review resolve REVIEW_ID COMMENT_ID [--repo PATH] [--json]` after addressing and verifying feedback to mark it resolved without changing its body or original anchor. The command prints the updated review's remaining unresolved comments and full counts; `show REVIEW_ID --all` still includes the resolved original. Repeating resolution succeeds without a write or revision bump. Both IDs must be positive and refer to the selected repository; invalid targets and absent storage fail without creating or changing stored data. Like deletion, resolution rejects stale saves from an editor holding the previous review revision.
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. 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. Review scope is the canonical checkout root plus the ordered, resolved Before/After identities, not branch/bookmark labels. Opening a different pair selects its existing review or starts an empty one; reopening the same pair restores its feedback, verdict, and file progress. A sole matching review restores automatically; multiple reviews for that scope open a floating selector. Move to an entry and press Enter, or use `:review.open id`. `:review.new` creates another review within the same scope. Opening or importing a review from a different scope is rejected rather than reassigning its feedback.
Working edits and in-place checkout refreshes retain the comparison's initial review scope. Explicitly opening a comparison resolves its endpoints again: changed Working contents or moved bookmarks can therefore select a different review. Ordinary source-buffer comments use a separate repository-local source scope, never the last comparison's review. Unsaved review drafts block scope changes until saved or discarded. Opening an empty scope does not write storage until a review change is committed.
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. 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.
The current SQLite format is schema 3: typed `reviews`, `comments`, and `reviewed_files` tables, not serialized JSON documents. Older databases are incompatible and are left untouched; there is no migration or automatic reset. JSON version 4 is the sharing format, independent of the database schema. The current SQLite format is schema 4: typed `reviews`, `comments`, and `reviewed_files` tables, not serialized JSON documents. Review rows store nullable Before/After identities; both are null for source reviews. Older databases, including schema 3, are incompatible and are left untouched; there is no migration or automatic reset. JSON version 5 is the sharing format, independent of the database schema.
Version 4 JSON is the agent-readable sharing format: repository root, summary, optional verdict, comments, and a required `reviewed_files` array. Each reviewed file stores its immutable per-file `revisions` pair (`file:` identities encoding mode, Git object ID or exact data, and notices), relative `path`, optional rename `old_path`, and optional exact live `after` text. Comments have stable IDs, bodies, required boolean `resolved` states, and anchors. New comments are unresolved. Database review IDs and optimistic storage revisions stay internal. Each anchor retains its immutable comparison revision, side, relative path (old path for renamed Before files), and exact whole-file text including the final newline. `start` and `end` contain zero-based source `row` and extended-grapheme `column` coordinates; `end` is exclusive. Columns count complete Unicode graphemes, not bytes, code points, UTF-16 units, or screen cells. Overview/window labels display one-based rows and columns. Older JSON formats are rejected and preserved without migration. Working comparisons use content-addressed snapshot IDs; captured text additionally freezes live unsaved edits. Stored reviews have a 64 MiB aggregate text budget including 64 bytes of overhead per comment and reviewed file; imported/exported JSON files have a separate 64 MiB serialized-size limit. Version 5 JSON is the agent-readable sharing format: repository root, `comparison` (an ordered Before/After identity pair, or `null` for source reviews), summary, optional verdict, comments, and a required `reviewed_files` array. Each reviewed file stores its immutable per-file `revisions` pair (`file:` identities encoding mode, Git object ID or exact data, and notices), relative `path`, optional rename `old_path`, and optional exact live `after` text. Comments have stable IDs, bodies, required boolean `resolved` states, and anchors. New comments are unresolved. Database review IDs and optimistic storage revisions stay internal. Each anchor retains its immutable comparison revision, side, relative path (old path for renamed Before files), and exact whole-file text including the final newline. `start` and `end` contain zero-based source `row` and extended-grapheme `column` coordinates; `end` is exclusive. Columns count complete Unicode graphemes, not bytes, code points, UTF-16 units, or screen cells. Overview/window labels display one-based rows and columns. Older JSON formats are rejected and preserved without migration. Working comparisons use content-addressed snapshot IDs; captured text additionally freezes live unsaved edits. Stored reviews have a 64 MiB aggregate text budget including 64 bytes of overhead per comment and reviewed file; imported/exported JSON files have a separate 64 MiB serialized-size limit.
Reopening requires the same repository root. 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. 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.
+45 -5
View File
@@ -773,6 +773,9 @@ pub struct DiffState {
pub error: Option<String>, pub error: Option<String>,
pub comparison: Option<Comparison>, pub comparison: Option<Comparison>,
pub comparison_id: Option<u64>, pub comparison_id: Option<u64>,
/// Frozen initial endpoint identities; checkout refreshes do not select a new review.
pub(crate) review_comparison: Option<[String; 2]>,
review_root: Option<PathBuf>,
pub reviewed_files: BTreeSet<usize>, pub reviewed_files: BTreeSet<usize>,
pub(crate) snapshot_roots: [Option<PathBuf>; 2], pub(crate) snapshot_roots: [Option<PathBuf>; 2],
pub files: Option<FilePicker>, pub files: Option<FilePicker>,
@@ -840,6 +843,8 @@ impl DiffState {
error: None, error: None,
comparison: None, comparison: None,
comparison_id: None, comparison_id: None,
review_comparison: None,
review_root: None,
reviewed_files: BTreeSet::new(), reviewed_files: BTreeSet::new(),
files: None, files: None,
snapshot_roots: [None, None], snapshot_roots: [None, None],
@@ -1221,6 +1226,13 @@ impl DiffState {
} }
Ok(Outcome::CheckoutRefresh(Some(mut comparison))) Ok(Outcome::CheckoutRefresh(Some(mut comparison)))
| Ok(Outcome::BookmarkRefresh(mut comparison)) => { | Ok(Outcome::BookmarkRefresh(mut comparison)) => {
if bookmark_refresh {
self.review_root = Some(
std::fs::canonicalize(&comparison.root)
.unwrap_or_else(|_| comparison.root.clone()),
);
self.review_comparison = Some(comparison.revisions.clone());
}
let pinned = !bookmark_refresh let pinned = !bookmark_refresh
&& self && self
.after .after
@@ -1344,6 +1356,10 @@ impl DiffState {
self.error = None; self.error = None;
} }
Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => { Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => {
self.review_root = Some(
std::fs::canonicalize(&comparison.root)
.unwrap_or_else(|_| comparison.root.clone()),
);
self.live_files.clear(); self.live_files.clear();
self.modified_after.clear(); self.modified_after.clear();
self.frozen_after.clear(); self.frozen_after.clear();
@@ -1364,6 +1380,7 @@ impl DiffState {
files.reveal_path(&file.path); files.reveal_path(&file.path);
} }
self.files = Some(files); self.files = Some(files);
self.review_comparison = Some(comparison.revisions.clone());
self.comparison = Some(comparison); self.comparison = Some(comparison);
self.comparison_id = Some(id); self.comparison_id = Some(id);
self.snapshot_roots = [None, None]; self.snapshot_roots = [None, None];
@@ -1895,6 +1912,7 @@ impl DiffState {
self.phase = Phase::After; self.phase = Phase::After;
self.comparison = None; self.comparison = None;
self.comparison_id = None; self.comparison_id = None;
self.review_comparison = None;
self.reviewed_files.clear(); self.reviewed_files.clear();
self.queue(operation); self.queue(operation);
} }
@@ -2208,6 +2226,27 @@ impl DiffState {
.files .files
.get(index) .get(index)
.ok_or("No changed file is selected")?; .ok_or("No changed file is selected")?;
if comparison
.revisions
.iter()
.all(|revision| !revision.is_empty())
{
if !review.initialized && review.document.root.as_os_str().is_empty() {
review.document = crate::review::Review::new(
self.review_root
.as_ref()
.unwrap_or(&comparison.root)
.clone(),
);
review.document.comparison = self.review_comparison.clone();
review.initialized = true;
}
if &review.document.root != self.review_root.as_ref().unwrap_or(&comparison.root)
|| review.document.comparison != self.review_comparison
{
return Err("Select the review for this comparison first");
}
}
let reviewed = !self.reviewed_files.remove(&index); let reviewed = !self.reviewed_files.remove(&index);
if reviewed { if reviewed {
self.reviewed_files.insert(index); self.reviewed_files.insert(index);
@@ -2218,9 +2257,6 @@ impl DiffState {
.iter() .iter()
.all(|revision| !revision.is_empty()) .all(|revision| !revision.is_empty())
{ {
if review.document.root.as_os_str().is_empty() {
review.document = crate::review::Review::new(comparison.root.clone());
}
let records = &mut review.document.reviewed_files; let records = &mut review.document.reviewed_files;
records.retain(|record| { records.retain(|record| {
record.revisions record.revisions
@@ -2255,7 +2291,9 @@ impl DiffState {
let Some(comparison) = self.comparison.as_ref() else { let Some(comparison) = self.comparison.as_ref() else {
return; return;
}; };
if review.document.root != comparison.root { if &review.document.root != self.review_root.as_ref().unwrap_or(&comparison.root)
|| review.document.comparison != self.review_comparison
{
return; return;
} }
let records: BTreeMap<_, _> = review let records: BTreeMap<_, _> = review
@@ -2703,7 +2741,9 @@ impl DiffState {
self.modified_after.insert(self.file); self.modified_after.insert(self.file);
} }
let comparison = self.comparison.as_ref().unwrap(); let comparison = self.comparison.as_ref().unwrap();
let record = (review.document.root == comparison.root) let record = (&review.document.root
== self.review_root.as_ref().unwrap_or(&comparison.root)
&& review.document.comparison == self.review_comparison)
.then(|| { .then(|| {
review.document.reviewed_files.iter().position(|record| { review.document.reviewed_files.iter().position(|record| {
record.revisions record.revisions
+30 -2
View File
@@ -4593,6 +4593,16 @@ impl Editor {
.as_mut() .as_mut()
.is_some_and(|diff| diff.apply_result(id, result)); .is_some_and(|diff| diff.apply_result(id, result));
} }
let result = if let Ok(Outcome::BookmarkRefresh(comparison)) = &result
&& self.current_review_dirty()
&& self
.workspace_diff()
.is_some_and(|diff| diff.review_comparison.as_ref() != Some(&comparison.revisions))
{
Err("Save the local review before changing comparison endpoints".into())
} else {
result
};
self.outstanding_diff_request = None; self.outstanding_diff_request = None;
if self.diff.as_ref().is_some_and(DiffState::refresh_pending) if self.diff.as_ref().is_some_and(DiffState::refresh_pending)
&& !matches!(&result, Ok(Outcome::CheckoutRefresh(None))) && !matches!(&result, Ok(Outcome::CheckoutRefresh(None)))
@@ -4653,8 +4663,7 @@ impl Editor {
view.clamp(&self.active.lines); view.clamp(&self.active.lines);
self.active.view = view; self.active.view = view;
} }
if self.review_directory.is_some() if self.resolver().is_none()
&& self.resolver().is_none()
&& self && self
.workspace_diff() .workspace_diff()
.is_some_and(|diff| diff.phase == Phase::View) .is_some_and(|diff| diff.phase == Phase::View)
@@ -10132,6 +10141,9 @@ impl Editor {
self.active.view.mode = Mode::Normal; self.active.view.mode = Mode::Normal;
} }
if entry.action == Action::DiffModeToggle { if entry.action == Action::DiffModeToggle {
if self.local_review_close_blocked() {
return None;
}
let Some(diff) = self.diff.as_mut() else { let Some(diff) = self.diff.as_mut() else {
self.set_message("No comparison is open"); self.set_message("No comparison is open");
return None; return None;
@@ -10145,6 +10157,19 @@ impl Editor {
return None; return None;
} }
if entry.action == Action::DiffReviewToggle { if entry.action == Action::DiffReviewToggle {
if self
.picker_diff()
.is_none_or(|diff| diff.phase != Phase::View)
{
self.set_message("No comparison is open");
return None;
}
if self.resolver().is_none()
&& let Err(error) = self.initialize_local_review()
{
self.set_message(format!("Local review: {error}"));
return None;
}
let mut review = std::mem::take(&mut self.local_review); let mut review = std::mem::take(&mut self.local_review);
let result = self let result = self
.picker_diff_mut() .picker_diff_mut()
@@ -10254,6 +10279,9 @@ impl Editor {
self.active.view.command_line.clear(); self.active.view.command_line.clear();
self.active.view.mode = Mode::Normal; self.active.view.mode = Mode::Normal;
} }
if let Err(error) = self.initialize_local_review() {
self.set_message(format!("Local review: {error}"));
}
} else { } else {
self.set_message("The diff.close command takes no arguments"); self.set_message("The diff.close command takes no arguments");
} }
+70 -20
View File
@@ -34,12 +34,15 @@ impl Editor {
pub(super) fn initialize_local_review(&mut self) -> Result<(), String> { pub(super) fn initialize_local_review(&mut self) -> Result<(), String> {
let root = self.review_root()?; let root = self.review_root()?;
if self.local_review.document.root == root { let comparison = self.review_comparison();
if self.local_review.document.root == root
&& self.local_review.document.comparison == comparison
{
self.local_review.initialized = true; self.local_review.initialized = true;
return Ok(()); return Ok(());
} }
if self.current_review_dirty() { if self.current_review_dirty() {
return Err("Save the current review before changing repositories".into()); return Err("Save the current review before changing review scopes".into());
} }
let reviews = match self.review_directory.as_deref() { let reviews = match self.review_directory.as_deref() {
Some(directory) => crate::review::Store::open_read_only(directory)? Some(directory) => crate::review::Store::open_read_only(directory)?
@@ -49,12 +52,17 @@ impl Editor {
None => Vec::new(), None => Vec::new(),
}; };
self.detach_review_buffers(); self.detach_review_buffers();
let mut document = Review::new(root);
document.comparison = comparison.clone();
self.local_review = crate::review::ReviewState { self.local_review = crate::review::ReviewState {
document: Review::new(root), document,
initialized: true, initialized: true,
..Default::default() ..Default::default()
}; };
let mut reviews = reviews; let mut reviews: Vec<_> = reviews
.into_iter()
.filter(|review| review.comparison == comparison)
.collect();
if reviews.len() == 1 { if reviews.len() == 1 {
self.local_review.document = reviews.pop().unwrap(); self.local_review.document = reviews.pop().unwrap();
} else if reviews.len() > 1 { } else if reviews.len() > 1 {
@@ -63,8 +71,30 @@ impl Editor {
Ok(()) Ok(())
} }
fn review_comparison(&self) -> Option<[String; 2]> {
if self.active.review_buffer.is_some() {
return self.local_review.document.comparison.clone();
}
self.diff
.as_ref()
.and_then(|diff| diff.review_comparison.clone())
}
fn review_root(&self) -> Result<PathBuf, String> { fn review_root(&self) -> Result<PathBuf, String> {
if let Some(diff) = self.workspace_diff() { if self
.active
.review_buffer
.as_ref()
.is_some_and(|binding| binding.owner != self.tab_id)
{
return Err("Focus a source or comparison pane in this tab to select a review".into());
}
if self.active.review_buffer.is_some()
|| (self.active.path.is_none() && self.local_review.initialized && self.diff.is_none())
{
return Ok(self.local_review.document.root.clone());
}
if let Some(diff) = self.diff.as_ref() {
return diff return diff
.comparison .comparison
.as_ref() .as_ref()
@@ -73,18 +103,16 @@ impl Editor {
}) })
.ok_or_else(|| "Comparison is still loading".into()); .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 let path = self
.active .active
.identity .identity
.as_deref() .as_deref()
.or(self.active.path.as_deref()) .or(self.active.path.as_deref())
.ok_or("Save the source file before commenting")?; .ok_or("Save the source file before commenting")?;
crate::repository::discover_root(path.parent().ok_or("Source has no parent directory")?) let root = crate::repository::discover_root(
path.parent().ok_or("Source has no parent directory")?,
)?;
Ok(fs::canonicalize(&root).unwrap_or(root))
} }
fn open_review_selector(&mut self) -> Result<(), String> { fn open_review_selector(&mut self) -> Result<(), String> {
@@ -93,7 +121,12 @@ impl Editor {
.review_directory .review_directory
.as_deref() .as_deref()
.ok_or("Review storage is not configured")?; .ok_or("Review storage is not configured")?;
let reviews = crate::review::Store::open(directory)?.list(&root)?; let comparison = self.review_comparison();
let reviews = crate::review::Store::open(directory)?
.list(&root)?
.into_iter()
.filter(|review| review.comparison == comparison)
.collect();
self.show_review_selector(reviews) self.show_review_selector(reviews)
} }
@@ -241,6 +274,10 @@ impl Editor {
root: PathBuf, root: PathBuf,
source: ReviewSource<'_>, source: ReviewSource<'_>,
) -> Result<(), String> { ) -> Result<(), String> {
if self.current_review_dirty() {
return Err("Save the current local review before opening another".into());
}
let comparison = self.review_comparison();
let (document, path, dirty) = match source { let (document, path, dirty) = match source {
ReviewSource::Stored(id) => { ReviewSource::Stored(id) => {
let directory = self let directory = self
@@ -258,8 +295,15 @@ impl Editor {
Some(path.to_path_buf()), Some(path.to_path_buf()),
self.review_directory.is_some(), self.review_directory.is_some(),
), ),
ReviewSource::New => (Review::new(root), None, true), ReviewSource::New => {
let mut document = Review::new(root);
document.comparison = comparison.clone();
(document, None, true)
}
}; };
if document.comparison != comparison {
return Err("Review belongs to a different comparison or source scope".into());
}
// Retire old drafts before comment IDs can be reused. // Retire old drafts before comment IDs can be reused.
self.detach_review_buffers(); self.detach_review_buffers();
let state = &mut self.local_review; let state = &mut self.local_review;
@@ -431,9 +475,6 @@ impl Editor {
{ {
return Err("This review command takes no arguments".into()); return Err("This review command takes no arguments".into());
} }
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) { if !matches!(action, Action::ReviewSave | Action::ReviewCopy) {
self.autosave_local_review()?; self.autosave_local_review()?;
} }
@@ -602,7 +643,10 @@ impl Editor {
&self, &self,
anchor: &Anchor, anchor: &Anchor,
) -> Option<std::ops::Range<TextPosition>> { ) -> Option<std::ops::Range<TextPosition>> {
if self.active.review_buffer.is_some() || !anchor.revision.starts_with("working:") { if self.local_review.document.comparison.is_some()
|| self.active.review_buffer.is_some()
|| !anchor.revision.starts_with("working:")
{
return None; return None;
} }
let path = self let path = self
@@ -621,7 +665,9 @@ impl Editor {
} }
fn review_anchor_range(&self, anchor: &Anchor) -> Option<std::ops::Range<TextPosition>> { fn review_anchor_range(&self, anchor: &Anchor) -> Option<std::ops::Range<TextPosition>> {
if let Some(diff) = self.workspace_diff() { if self.local_review.document.comparison.is_some()
&& let Some(diff) = self.workspace_diff()
{
return diff.review_anchor_range(anchor, &self.local_review); return diff.review_anchor_range(anchor, &self.local_review);
} }
let path = self.local_review.document.root.join(&anchor.path); let path = self.local_review.document.root.join(&anchor.path);
@@ -642,7 +688,9 @@ impl Editor {
) )
} }
fn add_review_comment(&mut self) -> Result<(), String> { fn add_review_comment(&mut self) -> Result<(), String> {
if let Some(owner) = self.diff_owner { if self.local_review.document.comparison.is_some()
&& let Some(owner) = self.diff_owner
{
if self.pane_id != owner { if self.pane_id != owner {
self.select_pane(owner); self.select_pane(owner);
} }
@@ -801,7 +849,9 @@ impl Editor {
let anchor = anchor.clone(); let anchor = anchor.clone();
let mut stale = !current; let mut stale = !current;
self.local_review.selected = Some(id); self.local_review.selected = Some(id);
if let Some(owner) = self.diff_owner { if self.local_review.document.comparison.is_some()
&& let Some(owner) = self.diff_owner
{
let target = self let target = self
.workspace_diff() .workspace_diff()
.and_then(|diff| diff.comparison.as_ref()) .and_then(|diff| diff.comparison.as_ref())
+2
View File
@@ -99,6 +99,8 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
Local reviews: :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 Works in ordinary repository source buffers and diff text panes
Different resolved Before/After pairs select separate reviews; reopening a pair resumes its review
Working refreshes retain the active review; ordinary source reviews stay separate
Selected comments highlight their precise text Selected comments highlight their precise text
:review.edit/delete [ID], :review.next/previous, :review.overview :review.edit/delete [ID], :review.next/previous, :review.overview
:review.summary; :review.verdict looks_good|changes_requested|none :review.summary; :review.verdict looks_good|changes_requested|none
+67 -17
View File
@@ -195,6 +195,8 @@ pub struct ReviewedFile {
pub struct Review { pub struct Review {
pub version: u32, pub version: u32,
pub root: PathBuf, pub root: PathBuf,
/// Ordered frozen comparison endpoints; `None` is a source review.
pub comparison: Option<[String; 2]>,
pub summary: String, pub summary: String,
pub verdict: Option<Verdict>, pub verdict: Option<Verdict>,
pub comments: Vec<Comment>, pub comments: Vec<Comment>,
@@ -231,8 +233,9 @@ impl Review {
fs::canonicalize(&root).unwrap_or(root) fs::canonicalize(&root).unwrap_or(root)
}; };
Self { Self {
version: 4, version: 5,
root, root,
comparison: None,
summary: String::new(), summary: String::new(),
verdict: None, verdict: None,
comments: Vec::new(), comments: Vec::new(),
@@ -249,12 +252,20 @@ impl Review {
.iter() .iter()
.filter(|comment| comment.resolved) .filter(|comment| comment.resolved)
.count(); .count();
let (scope, before, arrow, after) = match &self.comparison {
Some([before, after]) => ("comparison ", before.as_str(), " → ", after.as_str()),
None => ("source", "", "", ""),
};
format!( format!(
"Review ID: {}{}{}\n Verdict: {}; {} unresolved, {} resolved ({} total)\n", "Review ID: {}{}{}\n Scope: {}{}{}{}\n Verdict: {}; {} unresolved, {} resolved ({} total)\n",
self.storage_id self.storage_id
.map_or_else(|| "null".into(), |id| id.to_string()), .map_or_else(|| "null".into(), |id| id.to_string()),
if self.summary.is_empty() { "" } else { ": " }, if self.summary.is_empty() { "" } else { ": " },
self.summary, self.summary,
scope,
before,
arrow,
after,
match self.verdict { match self.verdict {
Some(Verdict::LooksGood) => "looks_good", Some(Verdict::LooksGood) => "looks_good",
Some(Verdict::ChangesRequested) => "changes_requested", Some(Verdict::ChangesRequested) => "changes_requested",
@@ -325,12 +336,19 @@ impl Review {
} }
fn validate(&self) -> Result<(), String> { fn validate(&self) -> Result<(), String> {
if self.version != 4 { if self.version != 5 {
return Err(format!("Unsupported review version {}", self.version)); return Err(format!("Unsupported review version {}", self.version));
} }
if !self.root.is_absolute() || !self.root.is_dir() { if !self.root.is_absolute() || !self.root.is_dir() {
return Err("Review repository root must be an existing absolute directory".into()); return Err("Review repository root must be an existing absolute directory".into());
} }
if let Some(revisions) = &self.comparison
&& revisions
.iter()
.any(|revision| revision.is_empty() || revision.chars().any(char::is_control))
{
return Err("Review comparison endpoints require immutable revision identities".into());
}
let mut ids = BTreeSet::new(); let mut ids = BTreeSet::new();
for comment in &self.comments { for comment in &self.comments {
if comment.id == 0 || !ids.insert(comment.id) { if comment.id == 0 || !ids.insert(comment.id) {
@@ -392,7 +410,7 @@ impl Review {
} }
let header: Header = serde_json::from_slice(bytes) let header: Header = serde_json::from_slice(bytes)
.map_err(|error| format!("Invalid review JSON: {error}"))?; .map_err(|error| format!("Invalid review JSON: {error}"))?;
if header.version != 4 { if header.version != 5 {
return Err(format!("Unsupported review version {}", header.version)); return Err(format!("Unsupported review version {}", header.version));
} }
serde_json::from_slice(bytes).map_err(|error| format!("Invalid review JSON: {error}")) serde_json::from_slice(bytes).map_err(|error| format!("Invalid review JSON: {error}"))
@@ -495,7 +513,14 @@ const REVIEWS_SCHEMA: &str = "CREATE TABLE reviews (
root TEXT NOT NULL, root TEXT NOT NULL,
revision INTEGER NOT NULL CHECK (revision > 0), revision INTEGER NOT NULL CHECK (revision > 0),
summary TEXT NOT NULL, summary TEXT NOT NULL,
verdict TEXT CHECK (verdict IN ('looks_good', 'changes_requested')) verdict TEXT CHECK (verdict IN ('looks_good', 'changes_requested')),
before_revision TEXT,
after_revision TEXT,
CHECK (
(before_revision IS NULL AND after_revision IS NULL)
OR (before_revision IS NOT NULL AND after_revision IS NOT NULL
AND length(before_revision) > 0 AND length(after_revision) > 0)
)
) STRICT"; ) STRICT";
// SQLite integers are signed; decimal text preserves the model's full u64 comment IDs. // SQLite integers are signed; decimal text preserves the model's full u64 comment IDs.
@@ -578,7 +603,7 @@ impl Store {
.map_err(database_error)? .map_err(database_error)?
.collect::<Result<_, _>>() .collect::<Result<_, _>>()
.map_err(database_error)?; .map_err(database_error)?;
if version != 3 || schema != [COMMENTS_SCHEMA, REVIEWED_FILES_SCHEMA, REVIEWS_SCHEMA] { if version != 4 || schema != [COMMENTS_SCHEMA, REVIEWED_FILES_SCHEMA, REVIEWS_SCHEMA] {
return Err(format!( return Err(format!(
"Unsupported review database schema (version {version}); existing data was not changed" "Unsupported review database schema (version {version}); existing data was not changed"
)); ));
@@ -646,7 +671,7 @@ impl Store {
.execute_batch(REVIEWED_FILES_SCHEMA) .execute_batch(REVIEWED_FILES_SCHEMA)
.map_err(database_error)?; .map_err(database_error)?;
transaction transaction
.pragma_update(None, "user_version", 3) .pragma_update(None, "user_version", 4)
.map_err(database_error)?; .map_err(database_error)?;
transaction.commit().map_err(database_error)?; transaction.commit().map_err(database_error)?;
} else { } else {
@@ -673,7 +698,7 @@ impl Store {
let reviews = { let reviews = {
let mut statement = transaction let mut statement = transaction
.prepare( .prepare(
"SELECT id, root, revision, summary, verdict FROM reviews WHERE root = ?1 ORDER BY id DESC", "SELECT id, root, revision, summary, verdict, before_revision, after_revision FROM reviews WHERE root = ?1 ORDER BY id DESC",
) )
.map_err(database_error)?; .map_err(database_error)?;
let mut rows = statement.query([root_text]).map_err(database_error)?; let mut rows = statement.query([root_text]).map_err(database_error)?;
@@ -698,7 +723,7 @@ impl Store {
.map_err(database_error)?; .map_err(database_error)?;
let review = { let review = {
let mut statement = transaction let mut statement = transaction
.prepare("SELECT id, root, revision, summary, verdict FROM reviews WHERE id = ?1 AND root = ?2") .prepare("SELECT id, root, revision, summary, verdict, before_revision, after_revision FROM reviews WHERE id = ?1 AND root = ?2")
.map_err(database_error)?; .map_err(database_error)?;
let mut rows = statement let mut rows = statement
.query(params![id, root_text]) .query(params![id, root_text])
@@ -734,6 +759,9 @@ impl Store {
_ => return Err("Invalid review storage identity".into()), _ => return Err("Invalid review storage identity".into()),
}; };
let mut size = review.root.as_os_str().len() + review.summary.len(); let mut size = review.root.as_os_str().len() + review.summary.len();
if let Some(revisions) = &review.comparison {
size += revisions[0].len() + revisions[1].len();
}
for comment in &review.comments { for comment in &review.comments {
size = size size = size
.checked_add( .checked_add(
@@ -768,26 +796,34 @@ impl Store {
Some(Verdict::ChangesRequested) => Some("changes_requested"), Some(Verdict::ChangesRequested) => Some("changes_requested"),
None => None, None => None,
}; };
let before = review
.comparison
.as_ref()
.map(|revisions| revisions[0].as_str());
let after = review
.comparison
.as_ref()
.map(|revisions| revisions[1].as_str());
let transaction = self let transaction = self
.connection .connection
.transaction_with_behavior(TransactionBehavior::Immediate) .transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(database_error)?; .map_err(database_error)?;
let id = if let Some(id) = review.storage_id { let id = if let Some(id) = review.storage_id {
let changed = transaction.execute( let changed = transaction.execute(
"UPDATE reviews SET revision = ?1, summary = ?2, verdict = ?3 WHERE id = ?4 AND root = ?5 AND revision = ?6", "UPDATE reviews SET revision = ?1, summary = ?2, verdict = ?3 WHERE id = ?4 AND root = ?5 AND revision = ?6 AND before_revision IS ?7 AND after_revision IS ?8",
params![revision, review.summary, verdict, id, root, review.storage_revision], params![revision, review.summary, verdict, id, root, review.storage_revision, before, after],
).map_err(database_error)?; ).map_err(database_error)?;
if changed != 1 { if changed != 1 {
return Err( return Err(
"Review changed in another session; draft kept, reload before saving".into(), "Review changed in another session or comparison scope changed; draft kept, reload before saving".into(),
); );
} }
id id
} else { } else {
transaction transaction
.execute( .execute(
"INSERT INTO reviews (root, revision, summary, verdict) VALUES (?1, ?2, ?3, ?4)", "INSERT INTO reviews (root, revision, summary, verdict, before_revision, after_revision) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![root, revision, review.summary, verdict], params![root, revision, review.summary, verdict, before, after],
) )
.map_err(database_error)?; .map_err(database_error)?;
transaction.last_insert_rowid() transaction.last_insert_rowid()
@@ -906,9 +942,15 @@ fn stored_review(
|row| row.get(0), |row| row.get(0),
) )
.map_err(database_error)?; .map_err(database_error)?;
if stored_bytes < 0 let mut header_bytes = stored_root.len() + summary.len();
|| stored_bytes as u64 + stored_root.len() as u64 + summary.len() as u64 > LIMIT as u64 for index in [5, 6] {
{ if let rusqlite::types::ValueRef::Text(bytes) =
row.get_ref(index).map_err(database_error)?
{
header_bytes += bytes.len();
}
}
if stored_bytes < 0 || stored_bytes as u64 + header_bytes as u64 > LIMIT as u64 {
return Err("Review exceeds the 64 MiB limit".into()); return Err("Review exceeds the 64 MiB limit".into());
} }
let mut review = Review { let mut review = Review {
@@ -916,6 +958,14 @@ fn stored_review(
..Review::default() ..Review::default()
}; };
review.summary = summary.to_owned(); review.summary = summary.to_owned();
review.comparison = match (
row.get::<_, Option<String>>(5).map_err(database_error)?,
row.get::<_, Option<String>>(6).map_err(database_error)?,
) {
(None, None) => None,
(Some(before), Some(after)) => Some([before, after]),
_ => return Err("Invalid stored review comparison endpoints".into()),
};
review.verdict = match row review.verdict = match row
.get::<_, Option<String>>(4) .get::<_, Option<String>>(4)
.map_err(database_error)? .map_err(database_error)?
+1
View File
@@ -109,6 +109,7 @@ fn envelope(review: &Review, all: bool, comments: bool) -> Value {
"repository": review.root, "repository": review.root,
"id": review.storage_id, "id": review.storage_id,
"revision": review.storage_revision, "revision": review.storage_revision,
"comparison": review.comparison,
"summary": review.summary, "summary": review.summary,
"verdict": review.verdict, "verdict": review.verdict,
"counts": { "total": review.comments.len(), "resolved": resolved, "unresolved": review.comments.len() - resolved }, "counts": { "total": review.comments.len(), "resolved": resolved, "unresolved": review.comments.len() - resolved },
+22 -2
View File
@@ -154,6 +154,7 @@ fn cli_resolves_only_the_requested_comment_and_preserves_original_feedback() {
let storage = root.join("reviews"); let storage = root.join("reviews");
let mut store = Store::open(&storage).unwrap(); let mut store = Store::open(&storage).unwrap();
let mut review = Review::new(root.join("repo")); let mut review = Review::new(root.join("repo"));
review.comparison = Some(["frozen-base".into(), "working:frozen-tip".into()]);
review.summary = "Keep summary".into(); review.summary = "Keep summary".into();
review.verdict = Some(Verdict::ChangesRequested); review.verdict = Some(Verdict::ChangesRequested);
review.comments = [u64::MAX, 2, 3] review.comments = [u64::MAX, 2, 3]
@@ -176,6 +177,11 @@ fn cli_resolves_only_the_requested_comment_and_preserves_original_feedback() {
let mut other = Review::new(root.join("other")); let mut other = Review::new(root.join("other"));
other.comments = review.comments.clone(); other.comments = review.comments.clone();
store.save(&mut other).unwrap(); store.save(&mut other).unwrap();
let mut source = Review::new(review.root.clone());
store.save(&mut source).unwrap();
let mut reversed = Review::new(review.root.clone());
reversed.comparison = Some(["working:frozen-tip".into(), "frozen-base".into()]);
store.save(&mut reversed).unwrap();
let id = review.storage_id.unwrap(); let id = review.storage_id.unwrap();
let id_text = id.to_string(); let id_text = id.to_string();
let cli = |repo: &str, args: &[&str]| { let cli = |repo: &str, args: &[&str]| {
@@ -214,6 +220,7 @@ fn cli_resolves_only_the_requested_comment_and_preserves_original_feedback() {
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["id"], id); assert_eq!(json["id"], id);
assert_eq!(json["revision"], review.storage_revision + 1); assert_eq!(json["revision"], review.storage_revision + 1);
assert_eq!(json["comparison"], serde_json::json!(review.comparison));
assert_eq!( assert_eq!(
json["counts"], json["counts"],
serde_json::json!({"total": 3, "resolved": 2, "unresolved": 1}) serde_json::json!({"total": 3, "resolved": 2, "unresolved": 1})
@@ -227,6 +234,7 @@ fn cli_resolves_only_the_requested_comment_and_preserves_original_feedback() {
assert_eq!(loaded.summary, review.summary); assert_eq!(loaded.summary, review.summary);
assert_eq!(loaded.verdict, review.verdict); assert_eq!(loaded.verdict, review.verdict);
assert_eq!(loaded.reviewed_files, review.reviewed_files); assert_eq!(loaded.reviewed_files, review.reviewed_files);
assert_eq!(loaded.comparison, review.comparison);
assert!(store.save(&mut review).is_err()); // Stale editors cannot undo resolution. assert!(store.save(&mut review).is_err()); // Stale editors cannot undo resolution.
assert_eq!(store.load(&review.root, id).unwrap().comments, expected); assert_eq!(store.load(&review.root, id).unwrap().comments, expected);
@@ -264,8 +272,20 @@ fn cli_resolves_only_the_requested_comment_and_preserves_original_feedback() {
let output = cli("repo", &["list", "--json"]); let output = cli("repo", &["list", "--json"]);
assert!(output.status.success(), "{:?}", output); assert!(output.status.success(), "{:?}", output);
let list: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); let list: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(list["reviews"].as_array().unwrap().len(), 1); assert_eq!(list["reviews"].as_array().unwrap().len(), 3);
assert_eq!(list["reviews"][0]["counts"], json["counts"]); assert_eq!(
list["reviews"][0]["comparison"],
serde_json::json!(reversed.comparison)
);
assert!(list["reviews"][1]["comparison"].is_null());
assert_eq!(list["reviews"][2]["comparison"], json["comparison"]);
assert_eq!(list["reviews"][2]["counts"], json["counts"]);
let output = cli("repo", &["list"]);
assert!(output.status.success(), "{:?}", output);
let text = String::from_utf8(output.stdout).unwrap();
assert!(text.contains("Scope: source"));
assert!(text.contains("Scope: comparison frozen-base → working:frozen-tip"));
assert!(text.contains("Scope: comparison working:frozen-tip → frozen-base"));
let output = cli("repo", &["resolve", &id_text, "2"]); let output = cli("repo", &["resolve", &id_text, "2"]);
assert!(output.status.success(), "{:?}", output); assert!(output.status.success(), "{:?}", output);
let text = String::from_utf8(output.stdout).unwrap(); let text = String::from_utf8(output.stdout).unwrap();
+39 -5
View File
@@ -2370,6 +2370,7 @@ fn live_state(before: Vec<String>, after: Vec<String>, eof: bool) -> DiffState {
loaded: true, loaded: true,
}], }],
}); });
state.review_comparison = Some(["base".into(), "working:snapshot".into()]);
state.files = Some(FilePicker::from_paths( state.files = Some(FilePicker::from_paths(
PathBuf::new(), PathBuf::new(),
[(0, PathBuf::from("file"))], [(0, PathBuf::from("file"))],
@@ -2764,7 +2765,33 @@ fn restored_review_marks_use_file_identities_and_rename_paths_not_catalog_order(
} }
#[test] #[test]
fn unchanged_file_review_survives_other_files_changing() { fn review_marks_cannot_cross_comparison_scopes_even_with_matching_file_identities() {
let mut review = crate::review::ReviewState::default();
let mut state = DiffState::new();
let mut first = comparison();
first.files[0].review_revisions = Some(["file-before".into(), "file-after".into()]);
state.request_kind = Some(RequestKind::Comparison);
state.apply_result(1, Ok(Outcome::Comparison(first)));
state.toggle_reviewed(&mut review).unwrap();
let initial_scope = review.document.comparison.clone();
let mut second = comparison();
second.files[0].review_revisions = Some(["file-before".into(), "file-after".into()]);
second.revisions[1] = "another-head".into();
state.request_kind = Some(RequestKind::Comparison);
state.apply_result(2, Ok(Outcome::Comparison(second)));
state.restore_reviewed_files(&review);
assert!(state.reviewed_files.is_empty());
assert_eq!(
state.toggle_reviewed(&mut review),
Err("Select the review for this comparison first")
);
assert!(state.reviewed_files.is_empty());
assert_eq!(review.document.comparison, initial_scope);
assert_eq!(review.document.reviewed_files.len(), 1);
}
#[test]
fn working_refresh_retains_scope_and_unchanged_file_progress() {
let mut review = crate::review::ReviewState::default(); let mut review = crate::review::ReviewState::default();
use crate::repository::Repository; use crate::repository::Repository;
use std::sync::{Arc, atomic::AtomicBool}; use std::sync::{Arc, atomic::AtomicBool};
@@ -2825,20 +2852,27 @@ fn unchanged_file_review_survives_other_files_changing() {
.compare(2, &before, &after, ComparisonMode::Snapshots, &[]) .compare(2, &before, &after, ComparisonMode::Snapshots, &[])
.unwrap(); .unwrap();
assert_ne!(changes.revisions, original_revisions); assert_ne!(changes.revisions, original_revisions);
state.request_kind = Some(RequestKind::Comparison); state.after = Some(after);
state.apply_result(2, Ok(Outcome::Comparison(changes))); state.request_kind = None;
state.pending = None;
state.loading = false;
state.queue_checkout_refresh(true);
state.take_request();
assert!(state.apply_result(2, Ok(Outcome::CheckoutRefresh(Some(changes)))));
assert_eq!(state.review_comparison, Some(original_revisions));
review.document = store.load(&root, id).unwrap(); review.document = store.load(&root, id).unwrap();
state.restore_reviewed_files(&review); state.restore_reviewed_files(&review);
assert_eq!(state.reviewed_files, BTreeSet::from([0])); assert_eq!(state.reviewed_files, BTreeSet::from([0]));
state.load_file(0);
let detail = repository.load_file(2, 0).unwrap(); let detail = repository.load_file(2, 0).unwrap();
state.apply_result( assert!(state.apply_result(
3, 3,
Ok(Outcome::File { Ok(Outcome::File {
comparison_id: 2, comparison_id: 2,
file_index: 0, file_index: 0,
file: detail, file: detail,
}), }),
); ));
state.restore_reviewed_files(&review); state.restore_reviewed_files(&review);
assert_eq!(state.reviewed_files, BTreeSet::from([0])); assert_eq!(state.reviewed_files, BTreeSet::from([0]));
std::fs::remove_dir_all(root).unwrap(); std::fs::remove_dir_all(root).unwrap();
+323 -2
View File
@@ -3017,6 +3017,15 @@ fn diff_preserves_parked_buffers_visual_carets_and_insert_history() {
} }
fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathBuf, String)]> { fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathBuf, String)]> {
working_diff_with_revisions(editor, root, paths, ["base", "working-snapshot"])
}
fn working_diff_with_revisions(
editor: &mut Editor,
root: &Path,
paths: &[&str],
revisions: [&str; 2],
) -> Box<[(PathBuf, String)]> {
use crate::diff::{Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision}; use crate::diff::{Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision};
editor.execute("diff"); editor.execute("diff");
let (id, _) = editor.take_diff_request().unwrap(); let (id, _) = editor.take_diff_request().unwrap();
@@ -3070,7 +3079,7 @@ fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathB
Ok(Outcome::Comparison(Comparison { Ok(Outcome::Comparison(Comparison {
blame: Default::default(), blame: Default::default(),
root: root.canonicalize().unwrap(), root: root.canonicalize().unwrap(),
revisions: ["base".into(), "working-snapshot".into()], revisions: revisions.map(str::to_owned),
before: "base".into(), before: "base".into(),
after: "Working".into(), after: "Working".into(),
editable_paths: paths.iter().map(|path| PathBuf::from(*path)).collect(), editable_paths: paths.iter().map(|path| PathBuf::from(*path)).collect(),
@@ -3081,6 +3090,16 @@ fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathB
} }
fn historical_diff(editor: &mut Editor, root: &Path, before: Vec<String>, after: Vec<String>) { fn historical_diff(editor: &mut Editor, root: &Path, before: Vec<String>, after: Vec<String>) {
historical_diff_with_revisions(editor, root, before, after, ["base", "head"]);
}
fn historical_diff_with_revisions(
editor: &mut Editor,
root: &Path,
before: Vec<String>,
after: Vec<String>,
revisions: [&str; 2],
) {
use crate::diff::{Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision}; use crate::diff::{Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision};
editor.execute("diff"); editor.execute("diff");
let (id, _) = editor.take_diff_request().unwrap(); let (id, _) = editor.take_diff_request().unwrap();
@@ -3108,7 +3127,7 @@ fn historical_diff(editor: &mut Editor, root: &Path, before: Vec<String>, after:
Ok(Outcome::Comparison(Comparison { Ok(Outcome::Comparison(Comparison {
blame: Default::default(), blame: Default::default(),
root: root.to_owned(), root: root.to_owned(),
revisions: ["base".into(), "head".into()], revisions: revisions.map(str::to_owned),
before: "base".into(), before: "base".into(),
after: "head".into(), after: "head".into(),
editable_paths: Default::default(), editable_paths: Default::default(),
@@ -3154,6 +3173,305 @@ fn source_review_editor(directory: &Directory, text: &str) -> (Editor, PathBuf,
(editor, root, path) (editor, root, path)
} }
#[test]
fn comparison_reviews_use_ordered_resolved_identities_and_resume_only_the_same_pair() {
use crate::review::{Store, Verdict};
let directory = Directory::new();
let (mut editor, root, _) = source_review_editor(&directory, "source\n");
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
editor.execute("comment");
type_text(&mut editor, "iPair feedback");
editor.handle(Input::Escape);
editor.execute("w");
editor.handle(Input::Escape);
editor.execute("diff.review.toggle");
editor.execute("review.verdict looks_good");
let original = editor.local_review.document.clone();
editor.handle(Input::Escape);
for pair in [["other", "head"], ["base", "other"], ["head", "base"]] {
historical_diff_with_revisions(
&mut editor,
&root,
vec!["old".into()],
vec!["new".into()],
pair,
);
assert_eq!(
editor.local_review.document.comparison,
Some(pair.map(str::to_owned))
);
assert!(editor.local_review.document.comments.is_empty());
assert!(editor.local_review.document.verdict.is_none());
assert!(editor.local_review.document.reviewed_files.is_empty());
assert!(editor.workspace_diff().unwrap().reviewed_files.is_empty());
editor.execute("review.verdict changes_requested");
assert_ne!(editor.local_review.document.storage_id, original.storage_id);
editor.handle(Input::Escape);
}
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
assert_eq!(editor.local_review.document.storage_id, original.storage_id);
assert_eq!(
editor.local_review.document.comments[0].body,
"Pair feedback"
);
assert_eq!(
editor.local_review.document.verdict,
Some(Verdict::LooksGood)
);
assert_eq!(
editor.workspace_diff().unwrap().reviewed_files,
BTreeSet::from([0])
);
assert_eq!(
Store::open(&root.join("reviews"))
.unwrap()
.list(&root)
.unwrap()
.len(),
4
);
}
#[test]
fn source_and_comparison_reviews_remain_separate_in_shared_panes_and_tabs() {
let directory = Directory::new();
let (mut editor, root, _) = source_review_editor(&directory, "source\n");
let source = editor.pane_id;
editor.execute("comment");
type_text(&mut editor, "iSource feedback");
editor.handle(Input::Escape);
editor.execute("w");
let source_id = editor.local_review.document.storage_id;
editor.handle(Input::Escape);
editor.split_pane(SplitAxis::Vertical);
let comparison_pane = editor.pane_id;
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
editor.execute("comment");
type_text(&mut editor, "iComparison feedback");
editor.handle(Input::Escape);
editor.execute("w");
let comparison_id = editor.local_review.document.storage_id;
assert_ne!(comparison_id, source_id);
editor.handle(Input::Escape);
editor.select_pane(source);
editor.execute("review.edit 1");
assert_eq!(editor.local_review.document.storage_id, source_id);
assert!(editor.local_review.document.comparison.is_none());
assert_eq!(editor.context().text, "Source feedback");
editor.handle(Input::Escape);
editor.select_pane(comparison_pane);
editor.execute("review.edit 1");
assert_eq!(editor.local_review.document.storage_id, comparison_id);
assert_eq!(editor.context().text, "Comparison feedback");
editor.handle(Input::Escape);
editor.select_pane(source);
editor.execute("tab.new");
editor.execute("review.edit 1");
assert_eq!(editor.local_review.document.storage_id, source_id);
assert_eq!(editor.context().text, "Source feedback");
editor.handle(Input::Escape);
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
assert_eq!(editor.local_review.document.storage_id, comparison_id);
editor.execute("tab.previous");
editor.select_pane(comparison_pane);
editor.execute("review.edit 1");
assert_eq!(editor.context().text, "Comparison feedback");
}
#[test]
fn review_selectors_and_explicit_opens_reject_other_comparison_and_source_scopes() {
use crate::review::{Review, Store};
let directory = Directory::new();
let (mut editor, root, _) = source_review_editor(&directory, "source\n");
let mut store = Store::open(&root.join("reviews")).unwrap();
let mut source = Review::new(root.clone());
source.summary = "source-only".into();
store.save(&mut source).unwrap();
let mut other = Review::new(root.clone());
other.comparison = Some(["different".into(), "head".into()]);
other.summary = "other-pair-only".into();
store.save(&mut other).unwrap();
let other_json = root.join("other.json");
other.save(&other_json).unwrap();
let source_json = root.join("source.json");
source.save(&source_json).unwrap();
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
editor.execute("review.verdict looks_good");
let current = editor.local_review.document.clone();
for command in [
format!("review.open {}", source.storage_id.unwrap()),
format!("review.open {}", other.storage_id.unwrap()),
format!("review.open {}", source_json.display()),
format!("review.open {}", other_json.display()),
] {
editor.execute(&command);
assert!(
editor
.message()
.contains("different comparison or source scope")
);
assert_eq!(editor.local_review.document.storage_id, current.storage_id);
assert_eq!(editor.local_review.document.comparison, current.comparison);
assert_eq!(editor.local_review.document.verdict, current.verdict);
}
editor.execute("review.new");
let second_id = editor.local_review.document.storage_id;
editor.execute("review.open");
assert!(!editor.context().text.contains("source-only"));
assert!(!editor.context().text.contains("other-pair-only"));
editor.handle(Input::Enter);
assert_eq!(editor.local_review.document.storage_id, second_id);
editor.execute(&format!("review.open {}", current.storage_id.unwrap()));
assert_eq!(editor.local_review.document.storage_id, current.storage_id);
}
#[test]
fn dirty_review_drafts_guard_scope_replacement_new_reviews_and_mode_changes() {
let directory = Directory::new();
let (mut editor, root, _) = source_review_editor(&directory, "source\n");
let source = editor.pane_id;
editor.split_pane(SplitAxis::Vertical);
let comparison_pane = editor.pane_id;
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
editor.execute("comment");
type_text(&mut editor, "iUnsaved feedback");
editor.handle(Input::Escape);
let draft = editor.buffer_id();
let panel = editor.review_pane.unwrap();
let scope = editor.local_review.document.comparison.clone();
editor.execute("review.new");
assert!(editor.message().contains("Save the current local review"));
assert_eq!(editor.context().text, "Unsaved feedback");
editor.select_pane(source);
editor.execute("review.overview");
assert!(editor.message().contains("changing review scopes"));
assert_eq!(editor.local_review.document.comparison, scope);
editor.select_pane(comparison_pane);
let comparison = editor.workspace_diff().unwrap().comparison_id;
editor.act(Action::DiffModeToggle);
assert_eq!(editor.workspace_diff().unwrap().comparison_id, comparison);
assert!(editor.workspace_diff().unwrap().comparison.is_some());
editor.execute("diff");
assert_eq!(editor.workspace_diff().unwrap().comparison_id, comparison);
editor.select_pane(panel);
assert_eq!(editor.buffer_id(), draft);
assert_eq!(editor.context().text, "Unsaved feedback");
editor.execute("w");
editor.handle(Input::Escape);
editor.select_pane(source);
editor.execute("review.overview");
assert!(editor.local_review.document.comparison.is_none());
assert!(editor.local_review.document.comments.is_empty());
}
#[test]
fn working_refresh_keeps_review_scope_and_drafts_but_explicit_reopen_uses_new_identities() {
let directory = Directory::new();
let (mut editor, root, _) = source_review_editor(&directory, "source\n");
fs::write(root.join("file.txt"), "original\n").unwrap();
working_diff(&mut editor, &root, &["file.txt"]);
editor.handle(Input::Enter);
editor.execute("comment");
type_text(&mut editor, "iWorking feedback");
editor.handle(Input::Escape);
editor.execute("w");
editor.handle(Input::Escape);
let original_id = editor.local_review.document.storage_id;
let scope = editor.local_review.document.comparison.clone();
editor.execute("review.summary");
type_text(&mut editor, "iUnsaved working summary");
editor.handle(Input::Escape);
let panel = editor.review_pane.unwrap();
let owner = editor.diff_owner.unwrap();
editor.queue_checkout_refreshes(true);
let (_, request, _) = editor.take_next_diff_request().unwrap();
let mut replacement = editor.with_pane(owner, |editor| {
editor.diff.as_mut().unwrap().comparison.take().unwrap()
});
replacement.revisions[1] = "working:changed".into();
replacement.files[0].after = vec!["refreshed".into()];
assert!(editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(replacement)))));
assert_eq!(editor.local_review.document.comparison, scope);
assert_eq!(editor.local_review.document.storage_id, original_id);
assert_eq!(
editor.local_review.document.comments[0].body,
"Working feedback"
);
assert!(editor.current_review_dirty());
editor.select_pane(panel);
assert_eq!(editor.context().text, "Unsaved working summary");
editor.execute("w");
editor.handle(Input::Escape);
working_diff_with_revisions(
&mut editor,
&root,
&["file.txt"],
["base", "working:changed"],
);
assert!(editor.local_review.document.comments.is_empty());
assert!(editor.local_review.document.summary.is_empty());
editor.execute("review.verdict looks_good");
assert_ne!(editor.local_review.document.storage_id, original_id);
editor.handle(Input::Escape);
working_diff(&mut editor, &root, &["file.txt"]);
assert_eq!(editor.local_review.document.storage_id, original_id);
assert_eq!(
editor.local_review.document.summary,
"Unsaved working summary"
);
}
#[test]
fn moved_bookmark_selects_new_review_and_dirty_draft_blocks_endpoint_replacement() {
use crate::diff::Comparison;
let directory = Directory::new();
let (mut editor, root, _) = source_review_editor(&directory, "source\n");
historical_diff(&mut editor, &root, vec!["old".into()], vec!["new".into()]);
editor.execute("review.verdict looks_good");
let original_id = editor.local_review.document.storage_id;
editor.handle(Input::Escape);
editor.execute("review.summary");
type_text(&mut editor, "iBookmark draft");
editor.handle(Input::Escape);
let panel = editor.review_pane.unwrap();
let moved = || Comparison {
revisions: ["base".into(), "moved-head".into()],
root: root.clone(),
before: "base".into(),
after: "head".into(),
blame: Default::default(),
editable_paths: Default::default(),
files: Vec::new(),
};
editor.queue_checkout_refreshes(false);
let (_, request, _) = editor.take_next_diff_request().unwrap();
assert!(editor.apply_diff_result(request, Ok(Outcome::BookmarkRefresh(moved()))));
assert_eq!(editor.local_review.document.storage_id, original_id);
assert_eq!(
editor
.workspace_diff()
.unwrap()
.comparison
.as_ref()
.unwrap()
.revisions[1],
"head"
);
editor.select_pane(panel);
assert_eq!(editor.context().text, "Bookmark draft");
editor.execute("w");
editor.handle(Input::Escape);
editor.queue_checkout_refreshes(false);
let (_, request, _) = editor.take_next_diff_request().unwrap();
assert!(editor.apply_diff_result(request, Ok(Outcome::BookmarkRefresh(moved()))));
assert_eq!(
editor.local_review.document.comparison,
Some(["base".into(), "moved-head".into()])
);
assert!(editor.local_review.document.verdict.is_none());
assert!(editor.local_review.document.summary.is_empty());
}
#[test] #[test]
fn source_review_drafts_guard_source_pane_close_and_saved_panels_retarget() { fn source_review_drafts_guard_source_pane_close_and_saved_panels_retarget() {
let directory = Directory::new(); let directory = Directory::new();
@@ -3443,6 +3761,8 @@ fn review_navigation_maps_working_ranges_and_preserves_stale_snapshots() {
.unwrap() .unwrap()
.revisions[1] = "working:old".into(); .revisions[1] = "working:old".into();
editor.local_review.document = crate::review::Review::new(root); editor.local_review.document = crate::review::Review::new(root);
editor.local_review.document.comparison =
editor.workspace_diff().unwrap().review_comparison.clone();
let anchor = Anchor { let anchor = Anchor {
revision: "working:old".into(), revision: "working:old".into(),
path: "file.txt".into(), path: "file.txt".into(),
@@ -7595,6 +7915,7 @@ mod system_clipboard {
let root = fixture.0.0.canonicalize().unwrap(); let root = fixture.0.0.canonicalize().unwrap();
let storage = root.join("reviews"); let storage = root.join("reviews");
let mut review = Review::new(root.clone()); let mut review = Review::new(root.clone());
review.comparison = Some(["base".into(), "head".into()]);
review.summary = "Review summary".into(); review.summary = "Review summary".into();
review.verdict = Some(Verdict::ChangesRequested); review.verdict = Some(Verdict::ChangesRequested);
review.comments = vec![ review.comments = vec![
+145 -5
View File
@@ -151,7 +151,7 @@ fn database_restarts_preserve_identity_and_exact_stale_anchors() {
let exported = fs::read(&path).unwrap(); let exported = fs::read(&path).unwrap();
let json: serde_json::Value = serde_json::from_slice(&exported).unwrap(); let json: serde_json::Value = serde_json::from_slice(&exported).unwrap();
assert_eq!(json["comments"][0]["resolved"], true); assert_eq!(json["comments"][0]["resolved"], true);
assert_eq!(json["version"], 4); assert_eq!(json["version"], 5);
assert_eq!(json["reviewed_files"][0]["after"], "live 👩‍🔬\r\n"); assert_eq!(json["reviewed_files"][0]["after"], "live 👩‍🔬\r\n");
assert!(json["reviewed_files"][1]["after"].is_null()); assert!(json["reviewed_files"][1]["after"].is_null());
assert_eq!(json["reviewed_files"][2]["after"], ""); assert_eq!(json["reviewed_files"][2]["after"], "");
@@ -189,6 +189,139 @@ fn database_restarts_preserve_identity_and_exact_stale_anchors() {
assert_eq!(loaded.verdict, verdict); assert_eq!(loaded.verdict, verdict);
} }
#[test]
fn comparison_scopes_roundtrip_in_order_and_cannot_move_saved_reviews() {
let directory = directory();
let root = directory.0.canonicalize().unwrap();
let storage = root.join("storage");
let mut store = Store::open(&storage).unwrap();
let mut source = Review::new(root.clone());
store.save(&mut source).unwrap();
let endpoints = ["frozen-base".to_owned(), "working:frozen-tip".to_owned()];
let mut comparison = Review::new(root.clone());
comparison.comparison = Some(endpoints.clone());
store.save(&mut comparison).unwrap();
let id = comparison.storage_id.unwrap();
assert!(source.summary_text().contains("Scope: source"));
assert!(
comparison
.summary_text()
.contains("Scope: comparison frozen-base → working:frozen-tip")
);
let mut reversed = Review::new(root.clone());
reversed.comparison = Some([endpoints[1].clone(), endpoints[0].clone()]);
store.save(&mut reversed).unwrap();
drop(store);
let mut store = Store::open(&storage).unwrap();
let reviews = store.list(&root).unwrap();
assert_eq!(reviews.len(), 3);
assert_eq!(reviews[0].comparison, reversed.comparison);
assert_eq!(reviews[1].comparison, Some(endpoints.clone()));
assert_eq!(reviews[2].comparison, None);
let mut loaded = store.load(&root, id).unwrap();
let path = root.join("comparison.json");
loaded.save(&path).unwrap();
let json: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
assert_eq!(json["comparison"], serde_json::json!(endpoints));
let imported = Review::load(&path, &root).unwrap();
assert_eq!(imported.comparison, loaded.comparison);
loaded.comparison = None;
assert!(store.save(&mut loaded).unwrap_err().contains("scope"));
assert_eq!(store.load(&root, id).unwrap().comparison, Some(endpoints));
assert_eq!(loaded.storage_revision, 1);
source.comparison = reversed.comparison;
assert!(store.save(&mut source).is_err());
assert_eq!(
store
.load(&root, source.storage_id.unwrap())
.unwrap()
.comparison,
None
);
}
#[test]
fn invalid_comparison_scopes_and_old_json_are_rejected_without_replacing_data() {
let directory = directory();
let root = directory.0.canonicalize().unwrap();
let mut review = Review::new(root.clone());
let path = root.join("review.json");
review.save(&path).unwrap();
let original = fs::read(&path).unwrap();
let mut store = Store::open(&root.join("storage")).unwrap();
for endpoint in ["", "bad\nrevision", "bad\0revision"] {
for side in [0, 1] {
let mut endpoints = ["base".to_owned(), "tip".to_owned()];
endpoints[side] = endpoint.into();
review.comparison = Some(endpoints);
assert!(review.save(&path).unwrap_err().contains("immutable"));
assert!(store.save(&mut review).unwrap_err().contains("immutable"));
assert_eq!(fs::read(&path).unwrap(), original);
assert_eq!((review.storage_id, review.storage_revision), (None, 0));
}
}
assert!(store.list(&root).unwrap().is_empty());
let mut old: serde_json::Value = serde_json::from_slice(&original).unwrap();
old["version"] = 4.into();
old.as_object_mut().unwrap().remove("comparison");
let bytes = serde_json::to_vec(&old).unwrap();
fs::write(&path, &bytes).unwrap();
assert!(
Review::load(&path, &root)
.unwrap_err()
.contains("Unsupported review version 4")
);
assert_eq!(fs::read(&path).unwrap(), bytes);
let fresh = Review::new(root.clone());
assert!(fresh.save(&path).is_err());
assert_eq!(fs::read(&path).unwrap(), bytes);
}
#[test]
fn comparison_rows_enforce_complete_pairs_and_the_review_size_limit() {
let directory = directory();
let root = directory.0.canonicalize().unwrap();
let mut store = Store::open(&root.join("storage")).unwrap();
let mut review = Review::new(root.clone());
store.save(&mut review).unwrap();
let id = review.storage_id.unwrap();
for update in [
"before_revision = 'base'",
"after_revision = 'tip'",
"before_revision = '', after_revision = 'tip'",
"before_revision = 'base', after_revision = ''",
] {
assert!(
store
.connection
.execute(&format!("UPDATE reviews SET {update} WHERE id = ?1"), [id],)
.is_err()
);
}
store
.connection
.execute(
"UPDATE reviews SET before_revision = 'base', after_revision = char(10) WHERE id = ?1",
[id],
)
.unwrap();
assert!(store.load(&root, id).unwrap_err().contains("immutable"));
store
.connection
.execute(
"UPDATE reviews SET after_revision = hex(zeroblob(?1)) WHERE id = ?2",
params![(LIMIT / 2) as i64, id],
)
.unwrap();
assert!(store.load(&root, id).unwrap_err().contains("64 MiB"));
assert!(store.list(&root).unwrap_err().contains("64 MiB"));
review.comparison = Some(["base".into(), "x".repeat(LIMIT)]);
assert!(store.save(&mut review).unwrap_err().contains("64 MiB"));
let path = root.join("too-large.json");
assert!(review.save(&path).unwrap_err().contains("64 MiB"));
assert!(!path.exists());
}
#[test] #[test]
fn database_repository_identity_is_canonical_and_independent() { fn database_repository_identity_is_canonical_and_independent() {
let first = directory(); let first = directory();
@@ -268,9 +401,16 @@ fn database_conflicts_and_locked_writes_keep_drafts_and_identity() {
#[test] #[test]
fn incompatible_database_schemas_are_preserved() { fn incompatible_database_schemas_are_preserved() {
let previous = format!( let previous = format!(
"{REVIEWS_SCHEMA}; {COMMENTS_SCHEMA}; "CREATE TABLE reviews (
id INTEGER PRIMARY KEY CHECK (id > 0),
root TEXT NOT NULL,
revision INTEGER NOT NULL CHECK (revision > 0),
summary TEXT NOT NULL,
verdict TEXT CHECK (verdict IN ('looks_good', 'changes_requested'))
) STRICT;
{COMMENTS_SCHEMA}; {REVIEWED_FILES_SCHEMA};
INSERT INTO reviews VALUES (1, '/old/repo', 1, 'Preserve me', 'looks_good'); INSERT INTO reviews VALUES (1, '/old/repo', 1, 'Preserve me', 'looks_good');
PRAGMA user_version = 2" PRAGMA user_version = 3"
); );
for schema in [ for schema in [
previous.as_str(), previous.as_str(),
@@ -799,12 +939,12 @@ fn exact_grapheme_ranges_roundtrip_and_reject_invalid_coordinates() {
.as_object_mut() .as_object_mut()
.unwrap() .unwrap()
.remove("resolved"); .remove("resolved");
for version in [2, 3, 4] { for version in [2, 3, 4, 5] {
missing_resolution["version"] = version.into(); missing_resolution["version"] = version.into();
let bytes = serde_json::to_vec(&missing_resolution).unwrap(); let bytes = serde_json::to_vec(&missing_resolution).unwrap();
fs::write(&path, &bytes).unwrap(); fs::write(&path, &bytes).unwrap();
let error = Review::load(&path, &root).unwrap_err(); let error = Review::load(&path, &root).unwrap_err();
if version < 4 { if version < 5 {
assert!(error.contains("Unsupported review version")); assert!(error.contains("Unsupported review version"));
} else { } else {
assert!(error.contains("missing field")); assert!(error.contains("missing field"));
-4
View File
@@ -1272,10 +1272,6 @@ fn resolver_renders_aligned_native_draft_alternative_and_separate_history() {
assert!(!heading.contains(resolver.files[current_file].path.to_string_lossy().as_ref())); assert!(!heading.contains(resolver.files[current_file].path.to_string_lossy().as_ref()));
assert!(!heading.split_whitespace().any(|word| word == "M")); assert!(!heading.split_whitespace().any(|word| word == "M"));
} }
assert!(
line_at(&terminal, alternative, alternative.bottom() - 1)
.contains(&resolver.files[current_file].sources[alternative_index].label)
);
assert!( assert!(
line_at(&terminal, draft, draft.bottom() - 1) line_at(&terminal, draft, draft.bottom() - 1)
.chars() .chars()