feat: persist diff file review checkmarks
This commit is contained in:
@@ -1,23 +1,17 @@
|
|||||||
- [x] command autocompletion via `tab` (multiple presses cycles candidates)
|
- [ ] feature: diff view - GitHub code review integration after the local review UX is usable.
|
||||||
- [x] fix ruby linting for the vamp config file (see attached image)
|
|
||||||
- [x] execute terminal commands asynchronously using the `!` prefix (e.g. `!pwd`) and display the output in a floating window.
|
|
||||||
- while waiting on executed commands, show their delayed labels/spinners in one independently styled element just left of the battery status in the top bar (top-right corner)
|
|
||||||
- indicate also the command that we're waiting on, maybe something like the first three characters (followed by ellipses if truncated), e.g. `pwd <spinner>` or if the command was `echo $PATH`, then `ech... <spinner>`.
|
|
||||||
- [ ] save reviewed (checkmark) state for files in the diff view too - we already save comments, so the reviewed state should be an easy addition.
|
|
||||||
- [ ] diff view: GitHub code review integration after the local review UX is usable.
|
|
||||||
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
|
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
|
||||||
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
|
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
|
||||||
- keep authentication, network requests, publishing, and remote thread synchronization out of the local UX work; treat timed-out writes as unknown outcomes rather than automatically retrying.
|
- keep authentication, network requests, publishing, and remote thread synchronization out of the local UX work; treat timed-out writes as unknown outcomes rather than automatically retrying.
|
||||||
- define follow-on scope for reading/replying to remote threads, editing/deleting published comments, and resolving threads.
|
- define follow-on scope for reading/replying to remote threads, editing/deleting published comments, and resolving threads.
|
||||||
- [ ] take a look at the configuration options and their names, and consider whether renaming some of them is warranted, as well as maybe nesting some under some common namespace (like some already are).
|
- [ ] chore: take a look at the configuration options and their names, and consider whether renaming some of them is warranted, as well as maybe nesting some under some common namespace (like some already are).
|
||||||
- [ ] remove the diff palette polling fallback once a released Herdr fix delivers palette-ready DEC 2031 notifications, including palette-only changes with an unchanged light/dark preference. Related upstream issue: [herdrdev/herdr#4183](https://github.com/herdrdev/herdr/issues/4183).
|
- [ ] chore: remove the diff palette polling fallback once a released Herdr fix delivers palette-ready DEC 2031 notifications, including palette-only changes with an unchanged light/dark preference. Related upstream issue: [herdrdev/herdr#4183](https://github.com/herdrdev/herdr/issues/4183).
|
||||||
- verify that notification-triggered queries return fresh colors in Ghostty through Herdr before removal; retain startup, focus-regain, and notification-driven palette refresh.
|
- verify that notification-triggered queries return fresh colors in Ghostty through Herdr before removal; retain startup, focus-regain, and notification-driven palette refresh.
|
||||||
- [ ] pull a pr description and display it in a buffer:
|
- [ ] feature: pull a pr description and display it in a buffer:
|
||||||
- open a selector that allows you to pick a pr from remote
|
- open a selector that allows you to pick a pr from remote
|
||||||
- selector should be searchable
|
- selector should be searchable
|
||||||
- selected pr description is opened in a separate buffer/tab.
|
- selected pr description is opened in a separate buffer/tab.
|
||||||
- open related pr description directly from diff view (should be able to figure out the relevant pr from the branch involved in the diff - if jj, then from tracked bookmark; if just git, then from branch)
|
- open related pr description directly from diff view (should be able to figure out the relevant pr from the branch involved in the diff - if jj, then from tracked bookmark; if just git, then from branch)
|
||||||
- [ ] sandboxed, revision-specific LSP analysis for historical diff states and historical tabs.
|
- [ ] feature: sandboxed, revision-specific LSP analysis for historical diff states and historical tabs.
|
||||||
- current limitation: immutable snapshot buffers still use the live repository as their LSP workspace; cross-file analysis can mix historical text with current disk contents.
|
- current limitation: immutable snapshot buffers still use the live repository as their LSP workspace; cross-file analysis can mix historical text with current disk contents.
|
||||||
- lazily materialize the complete selected revision, including unchanged files, manifests, and project configuration; run a separate LSP instance rooted in that snapshot.
|
- lazily materialize the complete selected revision, including unchanged files, manifests, and project configuration; run a separate LSP instance rooted in that snapshot.
|
||||||
- mount historical source read-only and provide isolated writable scratch directories for caches, indexes, and build output; keep historical navigation targets read-only.
|
- mount historical source read-only and provide isolated writable scratch directories for caches, indexes, and build output; keep historical navigation targets read-only.
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ 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, SQLite storage through bundled rusqlite, and atomic JSON import/export with external-change protection. Schema 2 stores review identity, repository, revision, summary, and verdict in `reviews`; `comments` stores ordered comment IDs, bodies, resolution states, and explicit immutable anchor fields. 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 3 stores review identity, repository, revision, summary, and verdict in `reviews`; `comments` stores ordered comment IDs, bodies, resolution states, and explicit immutable anchor fields. `reviewed_files` stores revision pairs, paths, optional old paths, and optional exact live After text. Foreign keys, unique keys, and checks enforce relationships and scalar invariants. Full `u64` comment IDs use checked decimal text because SQLite integers are signed. No JSON documents are stored in SQLite.
|
||||||
`Store` retains compare-and-swap review revisions and atomic whole-review saves: metadata and replacement comment 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 overhead. JSON version 3 is an independent import/export format. Incompatible databases, including schema 1 document storage, 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. Read transactions reconstruct validated reviews from one consistent snapshot, including when listing in descending ID order. Stored text is bounded to 64 MiB per review, including a fixed per-comment/checkmark overhead. JSON version 4 is an independent import/export format. Incompatible databases, including schemas 1 and 2, are rejected and preserved without migration or reset. CLI metadata, sole-review restoration, and selector labels reuse loaded reviews; selector choices live only in the displayed buffer.
|
||||||
The review CLI keeps `list`/`show` read-only. Comment deletion validates the explicit review/comment IDs through an existing read-only store before opening it for a compare-and-swap save; absent storage and invalid targets do not create reviews or a database.
|
The review CLI keeps `list`/`show` read-only. Comment deletion validates the explicit review/comment IDs through an existing read-only store before opening it for a compare-and-swap save; absent storage and invalid targets do not create reviews or a database.
|
||||||
`editor_review` commits native comment/summary buffers on dismissal or switching, and verdicts/deletions immediately. It restores a sole stored review or opens the floating selector for multiple reviews. Exports retain their own file baseline and do not become automatic persistence targets; imports get fresh database identities. Review editors remain tab-owned native panes outside the workspace `PaneTree`. Storage opens lazily when a comparison is ready; the session supplies the configured or XDG default directory. Direct `Editor` callers inject a directory with `set_review_directory` to enable persistence.
|
`editor_review` commits native comment/summary buffers on dismissal or switching, and verdicts/deletions immediately. It restores a sole stored review or opens the floating selector for multiple reviews. Exports retain their own file baseline and do not become automatic persistence targets; imports get fresh database identities. Review editors remain tab-owned native panes outside the workspace `PaneTree`. Storage opens lazily when a comparison is ready; the session supplies the configured or XDG default directory. Direct `Editor` callers inject a directory with `set_review_directory` to enable persistence.
|
||||||
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.
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ Manual-resolution checks cover immediate persistence, reopen/export/import, inde
|
|||||||
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.
|
||||||
CLI deletion checks exercise explicit review/comment IDs, resolved-comment deletion, repository isolation, preserved summaries/verdicts/other comments, last-comment deletion, missing targets/storage, and stale-save conflicts through the executable. CLI smoke verifies persisted deletion and rejects a repeated deletion without advancing the review revision.
|
CLI deletion checks exercise explicit review/comment IDs, resolved-comment deletion, repository isolation, preserved summaries/verdicts/other comments, last-comment deletion, missing targets/storage, and stale-save conflicts through the executable. CLI smoke verifies persisted deletion and rejects a repeated deletion without advancing the review revision.
|
||||||
Relational-storage smoke inspects typed review/comment columns, round-trips exact Unicode anchors through the store and JSON export/import, deletes through the real CLI, and confirms the stored review and existing export remain intact.
|
Relational-storage smoke inspects typed review/comment columns, round-trips exact Unicode anchors through the store and JSON export/import, deletes through the real CLI, and confirms the stored review and existing export remain intact.
|
||||||
|
Reviewed-file checks cover SQLite/JSON round-trips, unmarking, exact revision pairs and rename paths across catalog reordering, lazy loading, live text/final-newline mismatch, edit-triggered invalidation, selected-review isolation, hidden-draft isolation, conflicts, and transactional rollback. Live PTY smoke marks a file in a real Git comparison, verifies the SQLite row, restarts the editor, observes the restored checkmark, unmarks it, and verifies row removal with unchanged source bytes.
|
||||||
|
|
||||||
|
|
||||||
Conflicted-revision regressions preserve unchanged symlinks, defer oversized unselected blobs until file selection, compare selected conflict text with jj's projection, and exclude unchanged conflicts when comparing later revisions. Live terminal smoke covers local bookmark selection and opening an actual conflicted file without changing the checkout.
|
Conflicted-revision regressions preserve unchanged symlinks, defer oversized unselected blobs until file selection, compare selected conflict text with jj's projection, and exclude unchanged conflicts when comparing later revisions. Live terminal smoke covers local bookmark selection and opening an actual conflicted file without changing the checkout.
|
||||||
|
|||||||
+5
-5
@@ -462,9 +462,9 @@ Mark or unmark a diff file with **`c` while browsing the changed-file tree**, **
|
|||||||
|
|
||||||
Collapsed folders show a green **`✓`** when every changed file beneath them is reviewed, including nested files and descendants hidden by filtering. Expanded folders stay unmarked. Folder marks update automatically when a descendant's review mark or the changed-file catalog changes.
|
Collapsed folders show a green **`✓`** when every changed file beneath them is reviewed, including nested files and descendants hidden by filtering. Expanded folders stay unmarked. Folder marks update automatically when a descendant's review mark or the changed-file catalog changes.
|
||||||
|
|
||||||
Review marks belong only to the open comparison and survive filtering, file navigation, lazy loading, tabs, split/unified/orientation changes, and pinned checkout-only transitions. A new or recomputed comparison clears them. Editing a writable After file clears only that file's mark; unchanged refreshes and saves preserve it. Marks do not change document text, undo history, or repository state, and are not persisted after closing the comparison.
|
Review marks are saved with the selected local review and restored when the same immutable Before/After revision pair and file paths are reopened. They survive filtering, file navigation, lazy loading, tabs, split/unified/orientation changes, and pinned checkout-only transitions. Other comparisons do not inherit their marks; returning to a previously reviewed comparison restores them. Editing a writable After file clears only that file's mark and saves the removal; unchanged refreshes and saves preserve it. Marks on live text also retain its exact contents, including the final newline, so different unsaved text cannot inherit a checkmark. Marks do not change document text, undo history, or repository state. Without configured SQLite storage, use `:diff.review.save path` to save them as JSON.
|
||||||
|
|
||||||
**Local review comments** are independent of GitHub and the file-reviewed checkmarks above. Focus either diff text pane and run **`:diff.comment`** to comment on the current line. A characterwise Visual selection (`v`) anchors its exact text, including partial lines and multiline spans; Visual Line (`V`) anchors whole selected lines. Type **`:diff.comment`** and press Enter, or use the palette/a configured binding. Multiple independent carets/ranges must be reviewed separately.
|
**Local review comments** share local storage with the file-reviewed checkmarks above; neither feature publishes to GitHub. Focus either diff text pane and run **`:diff.comment`** to comment on the current line. A characterwise Visual selection (`v`) anchors its exact text, including partial lines and multiline spans; Visual Line (`V`) anchors whole selected lines. Type **`:diff.comment`** and press Enter, or use the palette/a configured binding. Multiple independent carets/ranges must be reviewed separately.
|
||||||
|
|
||||||
Comments, summaries, and the review overview open in a focused floating editor over the whole diff text workspace, not a source-side split. The source layout, file explorer, and viewports stay unchanged. Normal editing, selections, and undo/redo work in editable review buffers. Escape in Normal mode or `:pane.close` dismisses the window and returns to the diff, discarding that editor's unsaved text. An unsaved new comment is not attached to the review; an existing comment or summary reopens with its last saved text and no discarded undo history. Insert/Visual/Command Escape first retains its usual modal behavior. Source rows are never replaced with comment text. Magenta **`○`** gutter markers identify saved comment ranges; **`●`** identifies the selected comment. Its exact current text range has a magenta background, including wrapped continuations and selected newline cells; native Visual selections take precedence. Stale comments have neither markers nor text highlighting. Markers preserve addition/deletion signs, appear only on the first wrapped segment, and also work in unified view.
|
Comments, summaries, and the review overview open in a focused floating editor over the whole diff text workspace, not a source-side split. The source layout, file explorer, and viewports stay unchanged. Normal editing, selections, and undo/redo work in editable review buffers. Escape in Normal mode or `:pane.close` dismisses the window and returns to the diff, discarding that editor's unsaved text. An unsaved new comment is not attached to the review; an existing comment or summary reopens with its last saved text and no discarded undo history. Insert/Visual/Command Escape first retains its usual modal behavior. Source rows are never replaced with comment text. Magenta **`○`** gutter markers identify saved comment ranges; **`●`** identifies the selected comment. Its exact current text range has a magenta background, including wrapped continuations and selected newline cells; native Visual selections take precedence. Stale comments have neither markers nor text highlighting. Markers preserve addition/deletion signs, appear only on the first wrapped segment, and also work in unified view.
|
||||||
|
|
||||||
@@ -485,7 +485,7 @@ Local review buffers (comments, summaries, overview, and stored-review selector)
|
|||||||
| `:diff.review.open [id or path]` | Show stored reviews, open a stored review ID, or import a JSON review as a new review |
|
| `:diff.review.open [id or path]` | Show stored reviews, open a stored review ID, or import a JSON review as a new review |
|
||||||
| `:diff.review.new` | Create a separate stored review for the current repository |
|
| `:diff.review.new` | Create a separate stored review for the current repository |
|
||||||
|
|
||||||
Comment and summary text is committed only by **`:w`** or **`:diff.review.save`**, which saves the entire review, including hidden drafts. Dismissing an editor discards its unsaved text; switching between review editors can retain hidden drafts but does not save them. Verdicts, deletions, and manual resolution changes commit immediately to local SQLite without committing draft text. `:diff.review.save path` or `:w path` additionally exports JSON; later metadata autosaves still go to SQLite and leave that export unchanged. Imports create a new stored review identity rather than overwriting another review.
|
Comment and summary text is committed only by **`:w`** or **`:diff.review.save`**, which saves the entire review, including hidden drafts. Dismissing an editor discards its unsaved text; switching between review editors can retain hidden drafts but does not save them. File checkmarks, verdicts, deletions, and manual resolution changes commit immediately to local SQLite without committing draft text. `:diff.review.save path` or `:w path` additionally exports JSON; later metadata autosaves still go to SQLite and leave that export unchanged. Imports create a new stored review identity rather than overwriting another review.
|
||||||
|
|
||||||
From the shell, use `vamp --review delete REVIEW_ID COMMENT_ID [--repo PATH] [--json]` to delete a stored comment immediately without opening the editor. Find IDs with `vamp --review list` and `vamp --review show REVIEW_ID --all`. The repository defaults to the current checkout. Both resolved and unresolved comments can be deleted; the review and any JSON exports remain intact. An editor holding the old review revision must reload before saving.
|
From the shell, use `vamp --review delete REVIEW_ID COMMENT_ID [--repo PATH] [--json]` to delete a stored comment immediately without opening the editor. Find IDs with `vamp --review list` and `vamp --review show REVIEW_ID --all`. The repository defaults to the current checkout. Both resolved and unresolved comments can be deleted; the review and any JSON exports remain intact. An editor holding the old review revision must reload before saving.
|
||||||
|
|
||||||
@@ -493,9 +493,9 @@ Storage defaults to `$XDG_DATA_HOME/vamp/reviews/reviews.sqlite3`, or `~/.local/
|
|||||||
|
|
||||||
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 2: typed `reviews` and `comments` tables, not serialized JSON documents. Schema 1 databases are incompatible and are left untouched; there is no migration or automatic reset. JSON version 3 remains the sharing format, independent of the database schema.
|
The current SQLite format is schema 3: typed `reviews`, `comments`, and `reviewed_files` tables, not serialized JSON documents. Older databases are incompatible and are left untouched; there is no migration or automatic reset. JSON version 4 is the sharing format, independent of the database schema.
|
||||||
|
|
||||||
Version 3 JSON is the agent-readable sharing format: repository root, summary, optional verdict, and comments with 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, including version 2, 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; imported/exported JSON files have a separate 64 MiB serialized-size limit.
|
Version 4 JSON is the agent-readable sharing format: repository root, summary, optional verdict, comments, and a required `reviewed_files` array. Each reviewed file stores its immutable `revisions` pair, relative `path`, optional rename `old_path`, and optional exact live `after` text. Comments have stable IDs, bodies, required boolean `resolved` states, and anchors. New comments are unresolved. Database review IDs and optimistic storage revisions stay internal. Each anchor retains its immutable comparison revision, side, relative path (old path for renamed Before files), and exact whole-file text including the final newline. `start` and `end` contain zero-based source `row` and extended-grapheme `column` coordinates; `end` is exclusive. Columns count complete Unicode graphemes, not bytes, code points, UTF-16 units, or screen cells. Overview/window labels display one-based rows and columns. Older JSON formats are rejected and preserved without migration. Working comparisons use content-addressed snapshot IDs; captured text additionally freezes live unsaved edits. Stored reviews have a 64 MiB aggregate text budget including 64 bytes of overhead per comment and reviewed file; imported/exported JSON files have a separate 64 MiB serialized-size limit.
|
||||||
|
|
||||||
Reopening requires the same repository root. Comments whose revision, path, or exact file text no longer matches are **stale**: their original anchors remain unchanged and their gutter markers disappear. Unloaded files are shown as **not checked** until visited; refresh the overview to update its statuses. There is no automatic reanchoring, server, authentication, or review-publishing operation.
|
Reopening requires the same repository root. Comments whose revision, path, or exact file text no longer matches are **stale**: their original anchors remain unchanged and their gutter markers disappear. Unloaded files are shown as **not checked** until visited; refresh the overview to update its statuses. There is no automatic reanchoring, server, authentication, or review-publishing operation.
|
||||||
|
|
||||||
|
|||||||
+110
-8
@@ -587,6 +587,8 @@ pub struct DiffState {
|
|||||||
pub live_revision: Option<u64>,
|
pub live_revision: Option<u64>,
|
||||||
pub follow_caret: bool,
|
pub follow_caret: bool,
|
||||||
live_files: BTreeSet<usize>,
|
live_files: BTreeSet<usize>,
|
||||||
|
/// Files edited since their frozen comparison was loaded; undo does not restore review marks.
|
||||||
|
modified_after: BTreeSet<usize>,
|
||||||
frozen_after: BTreeMap<usize, (Vec<String>, bool)>,
|
frozen_after: BTreeMap<usize, (Vec<String>, bool)>,
|
||||||
context_lines: usize,
|
context_lines: usize,
|
||||||
expand_lines: usize,
|
expand_lines: usize,
|
||||||
@@ -648,6 +650,7 @@ impl DiffState {
|
|||||||
live_revision: None,
|
live_revision: None,
|
||||||
follow_caret: false,
|
follow_caret: false,
|
||||||
live_files: BTreeSet::new(),
|
live_files: BTreeSet::new(),
|
||||||
|
modified_after: BTreeSet::new(),
|
||||||
frozen_after: BTreeMap::new(),
|
frozen_after: BTreeMap::new(),
|
||||||
context_lines: 10,
|
context_lines: 10,
|
||||||
expand_lines: 15,
|
expand_lines: 15,
|
||||||
@@ -1005,6 +1008,7 @@ impl DiffState {
|
|||||||
self.snapshot_roots = [None, None];
|
self.snapshot_roots = [None, None];
|
||||||
self.frozen_after.clear();
|
self.frozen_after.clear();
|
||||||
self.live_files.clear();
|
self.live_files.clear();
|
||||||
|
self.modified_after.clear();
|
||||||
self.live_buffer = None;
|
self.live_buffer = None;
|
||||||
self.live_revision = None;
|
self.live_revision = None;
|
||||||
self.refresh_view = None;
|
self.refresh_view = None;
|
||||||
@@ -1082,6 +1086,7 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => {
|
Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => {
|
||||||
self.live_files.clear();
|
self.live_files.clear();
|
||||||
|
self.modified_after.clear();
|
||||||
self.frozen_after.clear();
|
self.frozen_after.clear();
|
||||||
self.refresh_view = None;
|
self.refresh_view = None;
|
||||||
self.reviewed_files.clear();
|
self.reviewed_files.clear();
|
||||||
@@ -1777,14 +1782,82 @@ impl DiffState {
|
|||||||
} else {
|
} else {
|
||||||
self.file
|
self.file
|
||||||
};
|
};
|
||||||
if comparison.files.get(index).is_none() {
|
let file = comparison
|
||||||
return Err("No changed file is selected");
|
.files
|
||||||
}
|
.get(index)
|
||||||
if self.reviewed_files.remove(&index) {
|
.ok_or("No changed file is selected")?;
|
||||||
Ok(false)
|
let reviewed = !self.reviewed_files.remove(&index);
|
||||||
} else {
|
if reviewed {
|
||||||
self.reviewed_files.insert(index);
|
self.reviewed_files.insert(index);
|
||||||
Ok(true)
|
}
|
||||||
|
// Resolver comparisons have no immutable revision identity or local review.
|
||||||
|
if comparison
|
||||||
|
.revisions
|
||||||
|
.iter()
|
||||||
|
.all(|revision| !revision.is_empty())
|
||||||
|
{
|
||||||
|
if self.local_review.document.root.as_os_str().is_empty() {
|
||||||
|
self.local_review.document = crate::review::Review::new(comparison.root.clone());
|
||||||
|
}
|
||||||
|
let records = &mut self.local_review.document.reviewed_files;
|
||||||
|
records.retain(|record| {
|
||||||
|
record.revisions != comparison.revisions || record.path != file.path
|
||||||
|
});
|
||||||
|
if reviewed {
|
||||||
|
records.push(crate::review::ReviewedFile {
|
||||||
|
revisions: comparison.revisions.clone(),
|
||||||
|
path: file.path.clone(),
|
||||||
|
old_path: file.old_path.clone(),
|
||||||
|
after: self
|
||||||
|
.live_files
|
||||||
|
.contains(&index)
|
||||||
|
.then(|| file.after.join("\n")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.local_review.dirty = true;
|
||||||
|
}
|
||||||
|
Ok(reviewed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn restore_reviewed_files(&mut self) {
|
||||||
|
self.reviewed_files.clear();
|
||||||
|
let Some(comparison) = self.comparison.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if self.local_review.document.root != comparison.root {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let records: BTreeMap<_, _> = self
|
||||||
|
.local_review
|
||||||
|
.document
|
||||||
|
.reviewed_files
|
||||||
|
.iter()
|
||||||
|
.filter(|record| record.revisions == comparison.revisions)
|
||||||
|
.map(|record| (&record.path, record))
|
||||||
|
.collect();
|
||||||
|
for (index, file) in comparison.files.iter().enumerate() {
|
||||||
|
let Some(record) = records
|
||||||
|
.get(&file.path)
|
||||||
|
.filter(|record| record.old_path == file.old_path)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let matches = match &record.after {
|
||||||
|
None => !self.modified_after.contains(&index),
|
||||||
|
Some(text) => {
|
||||||
|
file.loaded
|
||||||
|
&& (text
|
||||||
|
.split('\n')
|
||||||
|
.eq(file.after.iter().map(String::as_str).chain(
|
||||||
|
((!self.live_files.contains(&index) && file.after_eof)
|
||||||
|
|| file.after.is_empty())
|
||||||
|
.then_some(""),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if matches {
|
||||||
|
self.reviewed_files.insert(index);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2173,7 +2246,6 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
let was_live = self.live_files.contains(&self.file);
|
let was_live = self.live_files.contains(&self.file);
|
||||||
let changed_lines = changed_line_range(&file.after, lines);
|
let changed_lines = changed_line_range(&file.after, lines);
|
||||||
if self.reviewed_files.contains(&self.file) {
|
|
||||||
let unchanged = if was_live {
|
let unchanged = if was_live {
|
||||||
changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2
|
changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2
|
||||||
} else {
|
} else {
|
||||||
@@ -2181,7 +2253,37 @@ impl DiffState {
|
|||||||
file.after_eof == eof && file.after == live_content(lines, eof)
|
file.after_eof == eof && file.after == live_content(lines, eof)
|
||||||
};
|
};
|
||||||
if !unchanged {
|
if !unchanged {
|
||||||
|
self.modified_after.insert(self.file);
|
||||||
|
}
|
||||||
|
let comparison = self.comparison.as_ref().unwrap();
|
||||||
|
let record = (self.local_review.document.root == comparison.root)
|
||||||
|
.then(|| {
|
||||||
|
self.local_review
|
||||||
|
.document
|
||||||
|
.reviewed_files
|
||||||
|
.iter()
|
||||||
|
.position(|record| {
|
||||||
|
record.revisions == comparison.revisions
|
||||||
|
&& record.path == file.path
|
||||||
|
&& record.old_path == file.old_path
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.flatten();
|
||||||
|
let matches = record
|
||||||
|
.and_then(|position| {
|
||||||
|
self.local_review.document.reviewed_files[position]
|
||||||
|
.after
|
||||||
|
.as_ref()
|
||||||
|
})
|
||||||
|
.map(|text| text.split('\n').eq(lines.iter().map(String::as_str)));
|
||||||
|
if matches == Some(true) {
|
||||||
|
self.reviewed_files.insert(self.file);
|
||||||
|
}
|
||||||
|
if self.reviewed_files.contains(&self.file) && !matches.unwrap_or(unchanged) {
|
||||||
self.reviewed_files.remove(&self.file);
|
self.reviewed_files.remove(&self.file);
|
||||||
|
if let Some(position) = record {
|
||||||
|
self.local_review.document.reviewed_files.remove(position);
|
||||||
|
self.local_review.dirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if was_live && changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2 {
|
if was_live && changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2 {
|
||||||
|
|||||||
+29
-5
@@ -4579,6 +4579,13 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
let refresh = diff.refresh_pending();
|
let refresh = diff.refresh_pending();
|
||||||
let previous_comparison = diff.comparison_id;
|
let previous_comparison = diff.comparison_id;
|
||||||
|
let restore_reviewed = matches!(
|
||||||
|
&result,
|
||||||
|
Ok(Outcome::Comparison(_)
|
||||||
|
| Outcome::File { .. }
|
||||||
|
| Outcome::CheckoutRefresh(Some(_))
|
||||||
|
| Outcome::BookmarkRefresh(_))
|
||||||
|
);
|
||||||
if !diff.apply_result(id, result) {
|
if !diff.apply_result(id, result) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -4601,6 +4608,12 @@ impl Editor {
|
|||||||
view.clamp(&self.active.lines);
|
view.clamp(&self.active.lines);
|
||||||
self.active.view = view;
|
self.active.view = view;
|
||||||
}
|
}
|
||||||
|
if let Err(error) = self.initialize_local_review() {
|
||||||
|
self.set_message(format!("Local review: {error}"));
|
||||||
|
}
|
||||||
|
if restore_reviewed && let Some(diff) = &mut self.diff {
|
||||||
|
diff.restore_reviewed_files();
|
||||||
|
}
|
||||||
let reviewed = self.diff.as_mut().and_then(|diff| {
|
let reviewed = self.diff.as_mut().and_then(|diff| {
|
||||||
(refresh
|
(refresh
|
||||||
&& diff
|
&& diff
|
||||||
@@ -4657,9 +4670,6 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.finish_review_navigation();
|
self.finish_review_navigation();
|
||||||
if let Err(error) = self.initialize_local_review() {
|
|
||||||
self.set_message(format!("Local review: {error}"));
|
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5177,6 +5187,9 @@ impl Editor {
|
|||||||
diff.follow_caret = true;
|
diff.follow_caret = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Err(error) = self.autosave_local_review() {
|
||||||
|
self.set_message(format!("Review not saved: {error}"));
|
||||||
|
}
|
||||||
let Some(focus) = self.diff_buffer_focus() else {
|
let Some(focus) = self.diff_buffer_focus() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -10027,8 +10040,19 @@ impl Editor {
|
|||||||
};
|
};
|
||||||
if entry.action == Action::DiffReviewToggle {
|
if entry.action == Action::DiffReviewToggle {
|
||||||
match diff.toggle_reviewed() {
|
match diff.toggle_reviewed() {
|
||||||
Ok(true) => self.set_message("File marked reviewed"),
|
Ok(reviewed) => {
|
||||||
Ok(false) => self.set_message("File marked unreviewed"),
|
if self.resolver().is_none()
|
||||||
|
&& let Err(error) = self.autosave_local_review()
|
||||||
|
{
|
||||||
|
self.set_message(format!("Review not saved: {error}"));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.set_message(if reviewed {
|
||||||
|
"File marked reviewed"
|
||||||
|
} else {
|
||||||
|
"File marked unreviewed"
|
||||||
|
});
|
||||||
|
}
|
||||||
Err(error) => self.set_message(error),
|
Err(error) => self.set_message(error),
|
||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ impl Editor {
|
|||||||
state.selected = None;
|
state.selected = None;
|
||||||
state.pending_navigation = None;
|
state.pending_navigation = None;
|
||||||
state.dirty = dirty;
|
state.dirty = dirty;
|
||||||
|
self.review_diff_mut().unwrap().restore_reviewed_files();
|
||||||
// Persistence failure leaves the identity change applied and unsaved.
|
// Persistence failure leaves the identity change applied and unsaved.
|
||||||
self.autosave_local_review()?;
|
self.autosave_local_review()?;
|
||||||
self.open_review_overview()
|
self.open_review_overview()
|
||||||
|
|||||||
+134
-16
@@ -86,6 +86,15 @@ pub struct Comment {
|
|||||||
pub resolved: bool,
|
pub resolved: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ReviewedFile {
|
||||||
|
pub revisions: [String; 2],
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub old_path: Option<PathBuf>,
|
||||||
|
pub after: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct Review {
|
pub struct Review {
|
||||||
@@ -94,6 +103,7 @@ pub struct Review {
|
|||||||
pub summary: String,
|
pub summary: String,
|
||||||
pub verdict: Option<Verdict>,
|
pub verdict: Option<Verdict>,
|
||||||
pub comments: Vec<Comment>,
|
pub comments: Vec<Comment>,
|
||||||
|
pub reviewed_files: Vec<ReviewedFile>,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub storage_id: Option<i64>,
|
pub storage_id: Option<i64>,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
@@ -126,11 +136,12 @@ impl Review {
|
|||||||
fs::canonicalize(&root).unwrap_or(root)
|
fs::canonicalize(&root).unwrap_or(root)
|
||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
version: 3,
|
version: 4,
|
||||||
root,
|
root,
|
||||||
summary: String::new(),
|
summary: String::new(),
|
||||||
verdict: None,
|
verdict: None,
|
||||||
comments: Vec::new(),
|
comments: Vec::new(),
|
||||||
|
reviewed_files: Vec::new(),
|
||||||
storage_id: None,
|
storage_id: None,
|
||||||
storage_revision: 0,
|
storage_revision: 0,
|
||||||
saved: RefCell::new(None),
|
saved: RefCell::new(None),
|
||||||
@@ -138,7 +149,7 @@ impl Review {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), String> {
|
fn validate(&self) -> Result<(), String> {
|
||||||
if self.version != 3 {
|
if self.version != 4 {
|
||||||
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() {
|
||||||
@@ -150,10 +161,7 @@ impl Review {
|
|||||||
return Err("Review comment IDs must be unique and nonzero".into());
|
return Err("Review comment IDs must be unique and nonzero".into());
|
||||||
}
|
}
|
||||||
let anchor = &comment.anchor;
|
let anchor = &comment.anchor;
|
||||||
crate::repository::safe_path(&anchor.path)?;
|
validate_path(&anchor.path)?;
|
||||||
if anchor.path.to_str().is_none_or(|path| path.contains('\0')) {
|
|
||||||
return Err("Review paths must be UTF-8 without NUL bytes".into());
|
|
||||||
}
|
|
||||||
if anchor.revision.is_empty() || anchor.revision.chars().any(char::is_control) {
|
if anchor.revision.is_empty() || anchor.revision.chars().any(char::is_control) {
|
||||||
return Err("Review anchors require an immutable revision identity".into());
|
return Err("Review anchors require an immutable revision identity".into());
|
||||||
}
|
}
|
||||||
@@ -181,6 +189,23 @@ impl Review {
|
|||||||
return Err("Review rows must be within the anchor text".into());
|
return Err("Review rows must be within the anchor text".into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let mut files = BTreeSet::new();
|
||||||
|
for file in &self.reviewed_files {
|
||||||
|
validate_path(&file.path)?;
|
||||||
|
if let Some(path) = &file.old_path {
|
||||||
|
validate_path(path)?;
|
||||||
|
}
|
||||||
|
if file
|
||||||
|
.revisions
|
||||||
|
.iter()
|
||||||
|
.any(|revision| revision.is_empty() || revision.chars().any(char::is_control))
|
||||||
|
{
|
||||||
|
return Err("Reviewed files require immutable revision identities".into());
|
||||||
|
}
|
||||||
|
if !files.insert((&file.revisions, &file.path)) {
|
||||||
|
return Err("Reviewed files must be unique by revision pair and path".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +216,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 != 3 {
|
if header.version != 4 {
|
||||||
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}"))
|
||||||
@@ -264,6 +289,14 @@ impl Review {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_path(path: &Path) -> Result<(), String> {
|
||||||
|
crate::repository::safe_path(path)?;
|
||||||
|
if path.to_str().is_none_or(|path| path.contains('\0')) {
|
||||||
|
return Err("Review paths must be UTF-8 without NUL bytes".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn default_directory() -> Result<PathBuf, String> {
|
pub fn default_directory() -> Result<PathBuf, String> {
|
||||||
directory_from(
|
directory_from(
|
||||||
std::env::var_os("XDG_DATA_HOME").as_deref(),
|
std::env::var_os("XDG_DATA_HOME").as_deref(),
|
||||||
@@ -313,6 +346,18 @@ const COMMENTS_SCHEMA: &str = "CREATE TABLE comments (
|
|||||||
CHECK (end_row > start_row OR (end_row = start_row AND end_column >= start_column))
|
CHECK (end_row > start_row OR (end_row = start_row AND end_column >= start_column))
|
||||||
) STRICT";
|
) STRICT";
|
||||||
|
|
||||||
|
const REVIEWED_FILES_SCHEMA: &str = "CREATE TABLE reviewed_files (
|
||||||
|
review_id INTEGER NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
|
||||||
|
position INTEGER NOT NULL CHECK (position >= 0),
|
||||||
|
before_revision TEXT NOT NULL CHECK (length(before_revision) > 0),
|
||||||
|
after_revision TEXT NOT NULL CHECK (length(after_revision) > 0),
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
old_path TEXT,
|
||||||
|
after_content TEXT,
|
||||||
|
PRIMARY KEY (review_id, before_revision, after_revision, path),
|
||||||
|
UNIQUE (review_id, position)
|
||||||
|
) STRICT";
|
||||||
|
|
||||||
pub struct Store {
|
pub struct Store {
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
}
|
}
|
||||||
@@ -357,7 +402,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 != 2 || schema != [COMMENTS_SCHEMA, REVIEWS_SCHEMA] {
|
if version != 3 || 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"
|
||||||
));
|
));
|
||||||
@@ -422,7 +467,10 @@ impl Store {
|
|||||||
.execute_batch(COMMENTS_SCHEMA)
|
.execute_batch(COMMENTS_SCHEMA)
|
||||||
.map_err(database_error)?;
|
.map_err(database_error)?;
|
||||||
transaction
|
transaction
|
||||||
.pragma_update(None, "user_version", 2)
|
.execute_batch(REVIEWED_FILES_SCHEMA)
|
||||||
|
.map_err(database_error)?;
|
||||||
|
transaction
|
||||||
|
.pragma_update(None, "user_version", 3)
|
||||||
.map_err(database_error)?;
|
.map_err(database_error)?;
|
||||||
transaction.commit().map_err(database_error)?;
|
transaction.commit().map_err(database_error)?;
|
||||||
} else {
|
} else {
|
||||||
@@ -518,6 +566,21 @@ impl Store {
|
|||||||
)
|
)
|
||||||
.ok_or("Review exceeds the 64 MiB limit")?;
|
.ok_or("Review exceeds the 64 MiB limit")?;
|
||||||
}
|
}
|
||||||
|
for file in &review.reviewed_files {
|
||||||
|
size = size
|
||||||
|
.checked_add(
|
||||||
|
file.revisions[0].len()
|
||||||
|
+ file.revisions[1].len()
|
||||||
|
+ file.path.as_os_str().len()
|
||||||
|
+ file
|
||||||
|
.old_path
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |path| path.as_os_str().len())
|
||||||
|
+ file.after.as_ref().map_or(0, String::len)
|
||||||
|
+ 64,
|
||||||
|
)
|
||||||
|
.ok_or("Review exceeds the 64 MiB limit")?;
|
||||||
|
}
|
||||||
if size > LIMIT {
|
if size > LIMIT {
|
||||||
return Err("Review exceeds the 64 MiB limit".into());
|
return Err("Review exceeds the 64 MiB limit".into());
|
||||||
}
|
}
|
||||||
@@ -550,7 +613,7 @@ impl Store {
|
|||||||
.map_err(database_error)?;
|
.map_err(database_error)?;
|
||||||
transaction.last_insert_rowid()
|
transaction.last_insert_rowid()
|
||||||
};
|
};
|
||||||
// ponytail: replace comment rows per save; diff rows if snapshot writes become costly.
|
// ponytail: replace child rows per save; diff rows if snapshot writes become costly.
|
||||||
transaction
|
transaction
|
||||||
.execute("DELETE FROM comments WHERE review_id = ?1", [id])
|
.execute("DELETE FROM comments WHERE review_id = ?1", [id])
|
||||||
.map_err(database_error)?;
|
.map_err(database_error)?;
|
||||||
@@ -584,6 +647,31 @@ impl Store {
|
|||||||
.map_err(database_error)?;
|
.map_err(database_error)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
transaction
|
||||||
|
.execute("DELETE FROM reviewed_files WHERE review_id = ?1", [id])
|
||||||
|
.map_err(database_error)?;
|
||||||
|
{
|
||||||
|
let mut insert = transaction
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO reviewed_files (review_id, position, before_revision,
|
||||||
|
after_revision, path, old_path, after_content)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
|
)
|
||||||
|
.map_err(database_error)?;
|
||||||
|
for (position, file) in review.reviewed_files.iter().enumerate() {
|
||||||
|
insert
|
||||||
|
.execute(params![
|
||||||
|
id,
|
||||||
|
sql_index(position)?,
|
||||||
|
file.revisions[0],
|
||||||
|
file.revisions[1],
|
||||||
|
file.path.to_str().unwrap(),
|
||||||
|
file.old_path.as_ref().map(|path| path.to_str().unwrap()),
|
||||||
|
file.after,
|
||||||
|
])
|
||||||
|
.map_err(database_error)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
transaction.commit().map_err(database_error)?;
|
transaction.commit().map_err(database_error)?;
|
||||||
review.storage_id = Some(id);
|
review.storage_id = Some(id);
|
||||||
review.storage_revision = revision;
|
review.storage_revision = revision;
|
||||||
@@ -624,17 +712,23 @@ fn stored_review(
|
|||||||
.map_err(database_error)?
|
.map_err(database_error)?
|
||||||
.as_str()
|
.as_str()
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
let comment_bytes: i64 = connection
|
let stored_bytes: i64 = connection
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT COALESCE(SUM(length(CAST(body AS BLOB)) + length(CAST(anchor_revision AS BLOB))
|
"SELECT COALESCE(SUM(size), 0) FROM (
|
||||||
+ length(CAST(path AS BLOB)) + length(CAST(content AS BLOB)) + 64), 0)
|
SELECT length(CAST(body AS BLOB)) + length(CAST(anchor_revision AS BLOB))
|
||||||
FROM comments WHERE review_id = ?1",
|
+ length(CAST(path AS BLOB)) + length(CAST(content AS BLOB)) + 64 AS size
|
||||||
|
FROM comments WHERE review_id = ?1
|
||||||
|
UNION ALL
|
||||||
|
SELECT length(CAST(before_revision AS BLOB)) + length(CAST(after_revision AS BLOB))
|
||||||
|
+ length(CAST(path AS BLOB)) + COALESCE(length(CAST(old_path AS BLOB)), 0)
|
||||||
|
+ COALESCE(length(CAST(after_content AS BLOB)), 0) + 64
|
||||||
|
FROM reviewed_files WHERE review_id = ?1)",
|
||||||
[id],
|
[id],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.map_err(database_error)?;
|
.map_err(database_error)?;
|
||||||
if comment_bytes < 0
|
if stored_bytes < 0
|
||||||
|| comment_bytes as u64 + stored_root.len() as u64 + summary.len() as u64 > LIMIT as u64
|
|| stored_bytes as u64 + stored_root.len() as u64 + summary.len() as u64 > LIMIT as u64
|
||||||
{
|
{
|
||||||
return Err("Review exceeds the 64 MiB limit".into());
|
return Err("Review exceeds the 64 MiB limit".into());
|
||||||
}
|
}
|
||||||
@@ -699,6 +793,30 @@ fn stored_review(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let mut statement = connection
|
||||||
|
.prepare(
|
||||||
|
"SELECT position, before_revision, after_revision, path, old_path, after_content
|
||||||
|
FROM reviewed_files WHERE review_id = ?1 ORDER BY position",
|
||||||
|
)
|
||||||
|
.map_err(database_error)?;
|
||||||
|
let mut rows = statement.query([id]).map_err(database_error)?;
|
||||||
|
while let Some(row) = rows.next().map_err(database_error)? {
|
||||||
|
if stored_index(row, 0)? != review.reviewed_files.len() {
|
||||||
|
return Err("Invalid stored reviewed-file order".into());
|
||||||
|
}
|
||||||
|
review.reviewed_files.push(ReviewedFile {
|
||||||
|
revisions: [
|
||||||
|
row.get(1).map_err(database_error)?,
|
||||||
|
row.get(2).map_err(database_error)?,
|
||||||
|
],
|
||||||
|
path: PathBuf::from(row.get::<_, String>(3).map_err(database_error)?),
|
||||||
|
old_path: row
|
||||||
|
.get::<_, Option<String>>(4)
|
||||||
|
.map_err(database_error)?
|
||||||
|
.map(PathBuf::from),
|
||||||
|
after: row.get(5).map_err(database_error)?,
|
||||||
|
});
|
||||||
|
}
|
||||||
review.validate()?;
|
review.validate()?;
|
||||||
review.storage_id = Some(id);
|
review.storage_id = Some(id);
|
||||||
review.storage_revision = revision;
|
review.storage_revision = revision;
|
||||||
|
|||||||
@@ -2163,6 +2163,51 @@ fn reviewed_files_survive_lazy_details_and_views_but_reset_on_comparison() {
|
|||||||
assert!(state.reviewed_files.is_empty());
|
assert!(state.reviewed_files.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restored_review_marks_use_revision_pairs_and_rename_paths_not_catalog_order() {
|
||||||
|
let mut state = DiffState::new();
|
||||||
|
state.request_kind = Some(RequestKind::Comparison);
|
||||||
|
state.apply_result(1, Ok(Outcome::Comparison(comparison())));
|
||||||
|
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||||
|
state.comparison.as_mut().unwrap().files.swap(0, 1);
|
||||||
|
state.restore_reviewed_files();
|
||||||
|
assert_eq!(state.reviewed_files, BTreeSet::from([1]));
|
||||||
|
state.comparison.as_mut().unwrap().files[1].old_path = Some("different.txt".into());
|
||||||
|
state.restore_reviewed_files();
|
||||||
|
assert!(state.reviewed_files.is_empty());
|
||||||
|
state.comparison.as_mut().unwrap().files[1].old_path = Some("old.txt".into());
|
||||||
|
for side in [0, 1] {
|
||||||
|
let original = state.comparison.as_ref().unwrap().revisions[side].clone();
|
||||||
|
state.comparison.as_mut().unwrap().revisions[side].push_str("-different");
|
||||||
|
state.restore_reviewed_files();
|
||||||
|
assert!(state.reviewed_files.is_empty());
|
||||||
|
state.comparison.as_mut().unwrap().revisions[side] = original;
|
||||||
|
}
|
||||||
|
state.comparison.as_mut().unwrap().files[1].loaded = false;
|
||||||
|
state.restore_reviewed_files();
|
||||||
|
assert_eq!(state.reviewed_files, BTreeSet::from([1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restored_live_review_marks_require_exact_text_and_final_newline() {
|
||||||
|
let mut state = live_state(vec!["base".into()], vec!["changed".into()], false);
|
||||||
|
state.refresh_after(&["unsaved".into(), String::new()], 1);
|
||||||
|
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||||
|
let document = state.local_review.document.clone();
|
||||||
|
let mut reopened = live_state(vec!["base".into()], vec!["changed".into()], false);
|
||||||
|
reopened.local_review.document = document.clone();
|
||||||
|
reopened.restore_reviewed_files();
|
||||||
|
assert!(reopened.reviewed_files.is_empty());
|
||||||
|
reopened.refresh_after(&["unsaved".into(), String::new()], 1);
|
||||||
|
assert_eq!(reopened.reviewed_files, BTreeSet::from([0]));
|
||||||
|
reopened.refresh_after(&["unsaved".into()], 2);
|
||||||
|
assert!(reopened.reviewed_files.is_empty());
|
||||||
|
assert!(reopened.local_review.document.reviewed_files.is_empty());
|
||||||
|
reopened.local_review.document = document;
|
||||||
|
reopened.restore_reviewed_files();
|
||||||
|
assert!(reopened.reviewed_files.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn working_review_marks_clear_only_for_changed_bytes_or_eof() {
|
fn working_review_marks_clear_only_for_changed_bytes_or_eof() {
|
||||||
for (after, eof, lines) in [
|
for (after, eof, lines) in [
|
||||||
|
|||||||
@@ -3206,6 +3206,73 @@ fn review_metadata_autosaves_do_not_commit_hidden_comment_or_summary_drafts() {
|
|||||||
assert!(!editor.local_reviews_dirty());
|
assert!(!editor.local_reviews_dirty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reviewed_file_marks_autosave_restore_and_stay_with_the_selected_review() {
|
||||||
|
use crate::review::{Review, Store};
|
||||||
|
let directory = Directory::new();
|
||||||
|
let root = directory.0.canonicalize().unwrap();
|
||||||
|
let storage = root.join("data");
|
||||||
|
let mut first = editor();
|
||||||
|
first.set_review_directory(storage.clone());
|
||||||
|
historical_diff(&mut first, &root, vec!["old".into()], vec!["new".into()]);
|
||||||
|
first.execute("diff.review.toggle");
|
||||||
|
let store = Store::open(&storage).unwrap();
|
||||||
|
let saved = store.list(&root).unwrap().pop().unwrap();
|
||||||
|
let id = saved.storage_id.unwrap();
|
||||||
|
assert_eq!(saved.reviewed_files[0].path, Path::new("file.txt"));
|
||||||
|
let export = root.join("review.json");
|
||||||
|
first.execute(&format!("diff.review.save {}", export.display()));
|
||||||
|
assert_eq!(
|
||||||
|
Review::load(&export, &root).unwrap().reviewed_files,
|
||||||
|
saved.reviewed_files
|
||||||
|
);
|
||||||
|
first.execute("diff.review.summary");
|
||||||
|
type_text(&mut first, "iUnsaved summary");
|
||||||
|
first.handle(Input::Escape);
|
||||||
|
first.execute("diff.review.toggle");
|
||||||
|
assert!(store.load(&root, id).unwrap().reviewed_files.is_empty());
|
||||||
|
assert_eq!(store.load(&root, id).unwrap().summary, "");
|
||||||
|
first.execute("diff.review.toggle");
|
||||||
|
drop(first);
|
||||||
|
let mut reopened = editor();
|
||||||
|
reopened.set_review_directory(storage.clone());
|
||||||
|
historical_diff(&mut reopened, &root, vec!["old".into()], vec!["new".into()]);
|
||||||
|
assert_eq!(
|
||||||
|
reopened.workspace_diff().unwrap().reviewed_files,
|
||||||
|
BTreeSet::from([0])
|
||||||
|
);
|
||||||
|
reopened.execute("diff.review.new");
|
||||||
|
assert!(reopened.workspace_diff().unwrap().reviewed_files.is_empty());
|
||||||
|
reopened.execute(&format!("diff.review.open {id}"));
|
||||||
|
assert_eq!(
|
||||||
|
reopened.workspace_diff().unwrap().reviewed_files,
|
||||||
|
BTreeSet::from([0])
|
||||||
|
);
|
||||||
|
reopened.handle(Input::Escape);
|
||||||
|
reopened.execute("diff.review.toggle");
|
||||||
|
assert!(store.load(&root, id).unwrap().reviewed_files.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editing_a_reviewed_working_file_persists_its_invalidation() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let root = directory.0.canonicalize().unwrap();
|
||||||
|
fs::write(root.join("file.txt"), "original\n").unwrap();
|
||||||
|
let storage = root.join("data");
|
||||||
|
let mut editor = editor();
|
||||||
|
editor.set_review_directory(storage.clone());
|
||||||
|
working_diff(&mut editor, &root, &["file.txt"]);
|
||||||
|
editor.handle(Input::Enter);
|
||||||
|
editor.execute("diff.review.toggle");
|
||||||
|
let store = crate::review::Store::open(&storage).unwrap();
|
||||||
|
let id = store.list(&root).unwrap()[0].storage_id.unwrap();
|
||||||
|
assert_eq!(store.load(&root, id).unwrap().reviewed_files.len(), 1);
|
||||||
|
type_text(&mut editor, "iEdited ");
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
assert!(editor.workspace_diff().unwrap().reviewed_files.is_empty());
|
||||||
|
assert!(store.load(&root, id).unwrap().reviewed_files.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sqlite_reviews_autosave_restore_select_and_keep_exports_independent() {
|
fn sqlite_reviews_autosave_restore_select_and_keep_exports_independent() {
|
||||||
use crate::review::{Review, Store, Verdict};
|
use crate::review::{Review, Store, Verdict};
|
||||||
@@ -5276,6 +5343,10 @@ fn diff_review_actions_preserve_readonly_text_history_and_parked_tabs() {
|
|||||||
editor.execute("tab.previous");
|
editor.execute("tab.previous");
|
||||||
assert_eq!(editor.tab_id(), first_tab);
|
assert_eq!(editor.tab_id(), first_tab);
|
||||||
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0]));
|
assert_eq!(editor.diff().unwrap().reviewed_files, BTreeSet::from([0]));
|
||||||
|
editor.execute(&format!(
|
||||||
|
"diff.review.save {}",
|
||||||
|
directory.path("review.json").display()
|
||||||
|
));
|
||||||
editor.execute("diff.close");
|
editor.execute("diff.close");
|
||||||
historical_diff(
|
historical_diff(
|
||||||
&mut editor,
|
&mut editor,
|
||||||
|
|||||||
+244
-6
@@ -38,6 +38,16 @@ fn database_restarts_preserve_identity_and_exact_stale_anchors() {
|
|||||||
body: "Please preserve 👩🔬".into(),
|
body: "Please preserve 👩🔬".into(),
|
||||||
resolved: true,
|
resolved: true,
|
||||||
});
|
});
|
||||||
|
review.reviewed_files = [Some("live 👩🔬\r\n"), None, Some("")]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, after)| ReviewedFile {
|
||||||
|
revisions: [format!("before-{index}"), "after".into()],
|
||||||
|
path: "日本語.rs".into(),
|
||||||
|
old_path: (index == 0).then(|| "old.rs".into()),
|
||||||
|
after: after.map(str::to_owned),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
{
|
{
|
||||||
let mut store = Store::open(&storage).unwrap();
|
let mut store = Store::open(&storage).unwrap();
|
||||||
store.save(&mut review).unwrap();
|
store.save(&mut review).unwrap();
|
||||||
@@ -50,10 +60,12 @@ fn database_restarts_preserve_identity_and_exact_stale_anchors() {
|
|||||||
assert_eq!(listed[0].storage_id, Some(id));
|
assert_eq!(listed[0].storage_id, Some(id));
|
||||||
assert_eq!(listed[0].summary, review.summary);
|
assert_eq!(listed[0].summary, review.summary);
|
||||||
assert_eq!(listed[0].comments, review.comments);
|
assert_eq!(listed[0].comments, review.comments);
|
||||||
|
assert_eq!(listed[0].reviewed_files, review.reviewed_files);
|
||||||
let mut loaded = store.load(&root, id).unwrap();
|
let mut loaded = store.load(&root, id).unwrap();
|
||||||
assert_eq!(loaded.storage_id, Some(id));
|
assert_eq!(loaded.storage_id, Some(id));
|
||||||
assert_eq!(loaded.storage_revision, 1);
|
assert_eq!(loaded.storage_revision, 1);
|
||||||
assert_eq!(loaded.comments, review.comments);
|
assert_eq!(loaded.comments, review.comments);
|
||||||
|
assert_eq!(loaded.reviewed_files, review.reviewed_files);
|
||||||
assert_eq!(loaded.verdict, review.verdict);
|
assert_eq!(loaded.verdict, review.verdict);
|
||||||
assert!(!loaded.comments[0].anchor.matches_lines(
|
assert!(!loaded.comments[0].anchor.matches_lines(
|
||||||
"working:new-snapshot",
|
"working:new-snapshot",
|
||||||
@@ -67,7 +79,10 @@ 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"], 3);
|
assert_eq!(json["version"], 4);
|
||||||
|
assert_eq!(json["reviewed_files"][0]["after"], "live 👩🔬\r\n");
|
||||||
|
assert!(json["reviewed_files"][1]["after"].is_null());
|
||||||
|
assert_eq!(json["reviewed_files"][2]["after"], "");
|
||||||
assert!(json.get("storage_id").is_none());
|
assert!(json.get("storage_id").is_none());
|
||||||
assert!(json.get("storage_revision").is_none());
|
assert!(json.get("storage_revision").is_none());
|
||||||
loaded.summary = "Database only".into();
|
loaded.summary = "Database only".into();
|
||||||
@@ -78,9 +93,28 @@ fn database_restarts_preserve_identity_and_exact_stale_anchors() {
|
|||||||
let mut imported = Review::load(&path, &root).unwrap();
|
let mut imported = Review::load(&path, &root).unwrap();
|
||||||
assert_eq!(imported.storage_id, None);
|
assert_eq!(imported.storage_id, None);
|
||||||
assert_eq!(imported.storage_revision, 0);
|
assert_eq!(imported.storage_revision, 0);
|
||||||
|
assert_eq!(imported.reviewed_files, review.reviewed_files);
|
||||||
store.save(&mut imported).unwrap();
|
store.save(&mut imported).unwrap();
|
||||||
assert_ne!(imported.storage_id, Some(id));
|
assert_ne!(imported.storage_id, Some(id));
|
||||||
assert_eq!(store.list(&root).unwrap().len(), 2);
|
assert_eq!(store.list(&root).unwrap().len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.load(&root, imported.storage_id.unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.reviewed_files,
|
||||||
|
review.reviewed_files,
|
||||||
|
);
|
||||||
|
let comments = loaded.comments.clone();
|
||||||
|
let summary = loaded.summary.clone();
|
||||||
|
let verdict = loaded.verdict;
|
||||||
|
loaded.reviewed_files.clear();
|
||||||
|
store.save(&mut loaded).unwrap();
|
||||||
|
drop(store);
|
||||||
|
let loaded = Store::open(&storage).unwrap().load(&root, id).unwrap();
|
||||||
|
assert!(loaded.reviewed_files.is_empty());
|
||||||
|
assert_eq!(loaded.comments, comments);
|
||||||
|
assert_eq!(loaded.summary, summary);
|
||||||
|
assert_eq!(loaded.verdict, verdict);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -122,12 +156,20 @@ fn database_conflicts_and_locked_writes_keep_drafts_and_identity() {
|
|||||||
let mut first = Store::open(&storage).unwrap();
|
let mut first = Store::open(&storage).unwrap();
|
||||||
let mut second = Store::open(&storage).unwrap();
|
let mut second = Store::open(&storage).unwrap();
|
||||||
let mut review = Review::new(root.clone());
|
let mut review = Review::new(root.clone());
|
||||||
|
review.reviewed_files.push(ReviewedFile {
|
||||||
|
revisions: ["before".into(), "after".into()],
|
||||||
|
path: "source.rs".into(),
|
||||||
|
old_path: None,
|
||||||
|
after: None,
|
||||||
|
});
|
||||||
first.save(&mut review).unwrap();
|
first.save(&mut review).unwrap();
|
||||||
let id = review.storage_id.unwrap();
|
let id = review.storage_id.unwrap();
|
||||||
let mut draft = second.load(&root, id).unwrap();
|
let mut draft = second.load(&root, id).unwrap();
|
||||||
review.summary = "First writer".into();
|
review.summary = "First writer".into();
|
||||||
|
review.reviewed_files[0].after = Some("First writer buffer\n".into());
|
||||||
first.save(&mut review).unwrap();
|
first.save(&mut review).unwrap();
|
||||||
draft.summary = "Second writer draft".into();
|
draft.summary = "Second writer draft".into();
|
||||||
|
draft.reviewed_files.clear();
|
||||||
assert!(
|
assert!(
|
||||||
second
|
second
|
||||||
.save(&mut draft)
|
.save(&mut draft)
|
||||||
@@ -137,6 +179,11 @@ fn database_conflicts_and_locked_writes_keep_drafts_and_identity() {
|
|||||||
assert_eq!(draft.summary, "Second writer draft");
|
assert_eq!(draft.summary, "Second writer draft");
|
||||||
assert_eq!((draft.storage_id, draft.storage_revision), (Some(id), 1));
|
assert_eq!((draft.storage_id, draft.storage_revision), (Some(id), 1));
|
||||||
assert_eq!(second.load(&root, id).unwrap().summary, "First writer");
|
assert_eq!(second.load(&root, id).unwrap().summary, "First writer");
|
||||||
|
assert_eq!(
|
||||||
|
second.load(&root, id).unwrap().reviewed_files,
|
||||||
|
review.reviewed_files,
|
||||||
|
);
|
||||||
|
assert!(draft.reviewed_files.is_empty());
|
||||||
first.connection.execute_batch("BEGIN IMMEDIATE").unwrap();
|
first.connection.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||||
let mut fresh = Review::new(root.clone());
|
let mut fresh = Review::new(root.clone());
|
||||||
assert!(second.save(&mut fresh).unwrap_err().contains("locked"));
|
assert!(second.save(&mut fresh).unwrap_err().contains("locked"));
|
||||||
@@ -148,7 +195,13 @@ 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!(
|
||||||
|
"{REVIEWS_SCHEMA}; {COMMENTS_SCHEMA};
|
||||||
|
INSERT INTO reviews VALUES (1, '/old/repo', 1, 'Preserve me', 'looks_good');
|
||||||
|
PRAGMA user_version = 2"
|
||||||
|
);
|
||||||
for schema in [
|
for schema in [
|
||||||
|
previous.as_str(),
|
||||||
"CREATE TABLE other (value TEXT); PRAGMA user_version = 42",
|
"CREATE TABLE other (value TEXT); PRAGMA user_version = 42",
|
||||||
"CREATE TABLE other (value TEXT); PRAGMA user_version = 1",
|
"CREATE TABLE other (value TEXT); PRAGMA user_version = 1",
|
||||||
"CREATE TABLE reviews (id INTEGER PRIMARY KEY, root TEXT NOT NULL,
|
"CREATE TABLE reviews (id INTEGER PRIMARY KEY, root TEXT NOT NULL,
|
||||||
@@ -163,6 +216,8 @@ fn incompatible_database_schemas_are_preserved() {
|
|||||||
connection.execute_batch(schema).unwrap();
|
connection.execute_batch(schema).unwrap();
|
||||||
drop(connection);
|
drop(connection);
|
||||||
let original = fs::read(&path).unwrap();
|
let original = fs::read(&path).unwrap();
|
||||||
|
assert!(Store::open_read_only(&directory.0).is_err());
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), original);
|
||||||
assert!(Store::open(&directory.0).err().unwrap().contains("schema"));
|
assert!(Store::open(&directory.0).err().unwrap().contains("schema"));
|
||||||
assert_eq!(fs::read(&path).unwrap(), original);
|
assert_eq!(fs::read(&path).unwrap(), original);
|
||||||
}
|
}
|
||||||
@@ -461,6 +516,12 @@ fn comment_rows_preserve_order_full_ids_and_atomic_replacement() {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
review.summary = "Original".into();
|
review.summary = "Original".into();
|
||||||
|
review.reviewed_files.push(ReviewedFile {
|
||||||
|
revisions: ["before".into(), "after".into()],
|
||||||
|
path: "source.rs".into(),
|
||||||
|
old_path: None,
|
||||||
|
after: None,
|
||||||
|
});
|
||||||
store.save(&mut review).unwrap();
|
store.save(&mut review).unwrap();
|
||||||
let id = review.storage_id.unwrap();
|
let id = review.storage_id.unwrap();
|
||||||
assert_eq!(store.load(&root, id).unwrap().comments, review.comments);
|
assert_eq!(store.load(&root, id).unwrap().comments, review.comments);
|
||||||
@@ -488,6 +549,7 @@ fn comment_rows_preserve_order_full_ids_and_atomic_replacement() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let revision = review.storage_revision;
|
let revision = review.storage_revision;
|
||||||
let original = review.comments.clone();
|
let original = review.comments.clone();
|
||||||
|
let original_files = review.reviewed_files.clone();
|
||||||
review.summary = "Unsaved".into();
|
review.summary = "Unsaved".into();
|
||||||
review.comments[0].body = "Changed".into();
|
review.comments[0].body = "Changed".into();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -501,10 +563,30 @@ fn comment_rows_preserve_order_full_ids_and_atomic_replacement() {
|
|||||||
assert_eq!(loaded.summary, "Original");
|
assert_eq!(loaded.summary, "Original");
|
||||||
assert_eq!(loaded.comments, original);
|
assert_eq!(loaded.comments, original);
|
||||||
assert_eq!(loaded.storage_revision, revision);
|
assert_eq!(loaded.storage_revision, revision);
|
||||||
|
assert_eq!(loaded.reviewed_files, original_files);
|
||||||
store
|
store
|
||||||
.connection
|
.connection
|
||||||
.execute_batch("DROP TRIGGER reject_comment")
|
.execute_batch("DROP TRIGGER reject_comment")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute_batch(
|
||||||
|
"CREATE TEMP TRIGGER reject_reviewed BEFORE INSERT ON reviewed_files
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'blocked reviewed file'); END",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
review.reviewed_files[0].after = Some("Unsaved buffer\n".into());
|
||||||
|
assert!(store.save(&mut review).is_err());
|
||||||
|
assert_eq!(review.storage_revision, revision);
|
||||||
|
let loaded = store.load(&root, id).unwrap();
|
||||||
|
assert_eq!(loaded.summary, "Original");
|
||||||
|
assert_eq!(loaded.comments, original);
|
||||||
|
assert_eq!(loaded.reviewed_files, original_files);
|
||||||
|
assert_eq!(loaded.storage_revision, revision);
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute_batch("DROP TRIGGER reject_reviewed")
|
||||||
|
.unwrap();
|
||||||
review.comments.reverse();
|
review.comments.reverse();
|
||||||
store.save(&mut review).unwrap();
|
store.save(&mut review).unwrap();
|
||||||
assert_eq!(store.load(&root, id).unwrap().comments, review.comments);
|
assert_eq!(store.load(&root, id).unwrap().comments, review.comments);
|
||||||
@@ -547,16 +629,16 @@ 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] {
|
for version in [2, 3, 4] {
|
||||||
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();
|
||||||
assert!(error.contains(if version == 2 {
|
if version < 4 {
|
||||||
"Unsupported review version 2"
|
assert!(error.contains("Unsupported review version"));
|
||||||
} else {
|
} else {
|
||||||
"missing field `resolved`"
|
assert!(error.contains("missing field"));
|
||||||
}));
|
}
|
||||||
assert_eq!(fs::read(&path).unwrap(), bytes);
|
assert_eq!(fs::read(&path).unwrap(), bytes);
|
||||||
}
|
}
|
||||||
for (field, value) in [
|
for (field, value) in [
|
||||||
@@ -584,3 +666,159 @@ fn exact_grapheme_ranges_roundtrip_and_reject_invalid_coordinates() {
|
|||||||
loaded.comments[0].anchor.end = TextPosition { row: 0, column: 0 };
|
loaded.comments[0].anchor.end = TextPosition { row: 0, column: 0 };
|
||||||
assert!(loaded.save(&root.join("backwards.json")).is_err());
|
assert!(loaded.save(&root.join("backwards.json")).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reviewed_files_reject_invalid_json_and_preserve_existing_data() {
|
||||||
|
let directory = directory();
|
||||||
|
let root = directory.0.canonicalize().unwrap();
|
||||||
|
let path = root.join("review.json");
|
||||||
|
let mut store = Store::open(&root.join("storage")).unwrap();
|
||||||
|
let mut review = Review::new(root.clone());
|
||||||
|
review.reviewed_files.push(ReviewedFile {
|
||||||
|
revisions: ["before".into(), "after".into()],
|
||||||
|
path: "source.rs".into(),
|
||||||
|
old_path: Some("old.rs".into()),
|
||||||
|
after: Some("Exact live text\r\n".into()),
|
||||||
|
});
|
||||||
|
store.save(&mut review).unwrap();
|
||||||
|
let valid = serde_json::to_value(&review).unwrap();
|
||||||
|
let mut invalid_documents = Vec::new();
|
||||||
|
for (field, value) in [
|
||||||
|
("path", serde_json::json!("")),
|
||||||
|
("path", serde_json::json!("../outside")),
|
||||||
|
("path", serde_json::json!("/absolute")),
|
||||||
|
("path", serde_json::json!(".git/config")),
|
||||||
|
("path", serde_json::json!("nul\0.rs")),
|
||||||
|
("old_path", serde_json::json!("../outside")),
|
||||||
|
("old_path", serde_json::json!("/absolute")),
|
||||||
|
("old_path", serde_json::json!(".jj/repo")),
|
||||||
|
("old_path", serde_json::json!("nul\0.rs")),
|
||||||
|
("revisions", serde_json::json!(["", "after"])),
|
||||||
|
("revisions", serde_json::json!(["before", ""])),
|
||||||
|
("revisions", serde_json::json!(["before\n", "after"])),
|
||||||
|
("revisions", serde_json::json!(["before", "after\0"])),
|
||||||
|
("revisions", serde_json::json!(["before"])),
|
||||||
|
("unexpected", serde_json::json!(true)),
|
||||||
|
] {
|
||||||
|
let mut invalid = valid.clone();
|
||||||
|
invalid["reviewed_files"][0][field] = value;
|
||||||
|
invalid_documents.push(invalid);
|
||||||
|
}
|
||||||
|
let mut duplicate = valid.clone();
|
||||||
|
duplicate["reviewed_files"]
|
||||||
|
.as_array_mut()
|
||||||
|
.unwrap()
|
||||||
|
.push(valid["reviewed_files"][0].clone());
|
||||||
|
invalid_documents.push(duplicate);
|
||||||
|
let mut missing = valid.clone();
|
||||||
|
missing.as_object_mut().unwrap().remove("reviewed_files");
|
||||||
|
invalid_documents.push(missing);
|
||||||
|
let mut old = valid;
|
||||||
|
old["version"] = 3.into();
|
||||||
|
invalid_documents.push(old);
|
||||||
|
for invalid in invalid_documents {
|
||||||
|
let bytes = serde_json::to_vec(&invalid).unwrap();
|
||||||
|
fs::write(&path, &bytes).unwrap();
|
||||||
|
assert!(Review::load(&path, &root).is_err());
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), bytes);
|
||||||
|
if let Ok(mut invalid) = serde_json::from_value::<Review>(invalid) {
|
||||||
|
assert!(store.save(&mut invalid).is_err());
|
||||||
|
assert!(invalid.save(&root.join("invalid.json")).is_err());
|
||||||
|
assert!(!root.join("invalid.json").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let loaded = store.load(&root, review.storage_id.unwrap()).unwrap();
|
||||||
|
assert_eq!(loaded.reviewed_files, review.reviewed_files);
|
||||||
|
assert_eq!(store.list(&root).unwrap().len(), 1);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::ffi::OsStringExt;
|
||||||
|
for old_path in [false, true] {
|
||||||
|
let mut invalid = review.clone();
|
||||||
|
let path = PathBuf::from(std::ffi::OsString::from_vec(vec![0xff]));
|
||||||
|
if old_path {
|
||||||
|
invalid.reviewed_files[0].old_path = Some(path);
|
||||||
|
} else {
|
||||||
|
invalid.reviewed_files[0].path = path;
|
||||||
|
}
|
||||||
|
assert!(store.save(&mut invalid).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reviewed_file_rows_enforce_identity_and_limit_snapshots_before_loading() {
|
||||||
|
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());
|
||||||
|
review.reviewed_files.push(ReviewedFile {
|
||||||
|
revisions: ["before".into(), "after".into()],
|
||||||
|
path: "source.rs".into(),
|
||||||
|
old_path: None,
|
||||||
|
after: None,
|
||||||
|
});
|
||||||
|
store.save(&mut review).unwrap();
|
||||||
|
let id = review.storage_id.unwrap();
|
||||||
|
for update in [
|
||||||
|
"review_id = 0",
|
||||||
|
"before_revision = ''",
|
||||||
|
"after_revision = ''",
|
||||||
|
"path = NULL",
|
||||||
|
"after_content = zeroblob(1)",
|
||||||
|
"position = -1",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute(
|
||||||
|
&format!("UPDATE reviewed_files SET {update} WHERE review_id = ?1"),
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
.is_err(),
|
||||||
|
"{update}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO reviewed_files
|
||||||
|
SELECT review_id, position + 1, before_revision, after_revision,
|
||||||
|
path, old_path, after_content FROM reviewed_files WHERE review_id = ?1",
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
for update in [
|
||||||
|
"before_revision = char(10)",
|
||||||
|
"after_revision = 'after' || char(0)",
|
||||||
|
"path = '../outside'",
|
||||||
|
"old_path = '../outside'",
|
||||||
|
"old_path = 'nul' || char(0)",
|
||||||
|
"position = 1",
|
||||||
|
] {
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute(
|
||||||
|
&format!("UPDATE reviewed_files SET {update} WHERE review_id = ?1"),
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.load(&root, id).is_err(), "{update}");
|
||||||
|
assert!(store.list(&root).is_err(), "{update}");
|
||||||
|
store.save(&mut review).unwrap();
|
||||||
|
}
|
||||||
|
store
|
||||||
|
.connection
|
||||||
|
.execute(
|
||||||
|
"UPDATE reviewed_files SET after_content = CAST(zeroblob(?1) AS TEXT)
|
||||||
|
WHERE review_id = ?2",
|
||||||
|
params![LIMIT as i64 + 1, id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.load(&root, id).unwrap_err().contains("64 MiB"));
|
||||||
|
assert!(store.list(&root).unwrap_err().contains("64 MiB"));
|
||||||
|
review.reviewed_files[0].after = Some("x".repeat(LIMIT));
|
||||||
|
assert!(store.save(&mut review).unwrap_err().contains("64 MiB"));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user