50 Commits

Author SHA1 Message Date
Valdemar Lorenzen 2368091d93 fix: simplify revision diff selection into a flat list
Revisions still used ancestry lanes and placed author/date/bookmark metadata before the summary. Show short change IDs and summaries first, move metadata to a dimmed detail row, and remove the obsolete picker graph projection.
2026-11-05 15:59:09 +01:00
Valdemar Lorenzen 1afc896cab fix: make bookmark diff selection a name-first list
Bookmarks shared revision-graph rendering, allowing ancestry lanes and revision metadata to crowd out reference names. Restrict graph projection to Revisions and reuse flat bookmark rows with commit summaries.
2026-11-05 15:59:09 +01:00
Valdemar Lorenzen e3d44f24c0 feat: show jj ancestry in the floating diff picker 2026-11-02 10:17:33 +01:00
hl-valdemar 496c93774f fix: render theme-aware software cursors and mode banners 2026-10-26 16:29:17 +01:00
hl-valdemar c6901eaaa6 fix: synchronize cursor and bar mode colors 2026-10-26 16:14:41 +01:00
hl-valdemar ce434a1c17 feat: configure cursor appearance by editing mode 2026-10-26 16:14:41 +01:00
hl-valdemar 846803a3e2 fix: hide commentless reviews from CLI listings 2026-10-25 21:34:45 +01:00
hl-valdemar 448c3bf477 feat: scope local reviews to comparisons 2026-10-25 01:18:25 +02:00
hl-valdemar 815fcccbe8 feat: fence review excerpts with language tags 2026-10-25 00:19:06 +02:00
hl-valdemar c1bff63111 feat: show base-relative changes in conflict panes 2026-10-25 00:11:40 +02:00
hl-valdemar c88582f2ba feat: resolve review comments through the CLI 2026-10-24 13:29:18 +02:00
hl-valdemar 6b04b6f0ac fix: keep explorer selections readable on light terminals 2026-10-23 12:07:43 +02:00
hl-valdemar c12d98e348 refactor: remove legacy diff review command aliases 2026-10-22 23:21:53 +02:00
hl-valdemar e6d94dcb41 feat: support comments in ordinary source buffers 2026-10-22 23:21:53 +02:00
hl-valdemar ac5c64981d fix: display diff totals as plain status text 2026-10-22 19:29:04 +02:00
hl-valdemar 8f4e0fa7fb fix: exempt configuration blocks from length lint 2026-10-22 11:35:07 +02:00
hl-valdemar 03931496bf feat: add Typst syntax and Tinymist defaults 2026-10-22 11:35:07 +02:00
Valdemar Lorenzen ea7c240eb6 fix: keep pull request filters session-local 2026-10-21 15:18:47 +02:00
Valdemar Lorenzen fbcb519e13 feat: add persistent pull request sort and filter menus 2026-10-21 13:39:27 +02:00
Valdemar Lorenzen 37b47ebc8a feat: sort and filter pull requests by review state 2026-10-21 12:53:22 +02:00
Valdemar Lorenzen 5c08aa0a67 chore: isolate tests from external services 2026-10-21 11:44:32 +02:00
Valdemar Lorenzen 0202644efa feat: configure personal pull request review indicators 2026-10-21 11:28:55 +02:00
Valdemar Lorenzen bd71d463a4 feat: mark pull requests approved by the authenticated user 2026-10-21 11:14:01 +02:00
Valdemar Lorenzen 6a359cc351 feat: separate draft pull requests into their own tab 2026-10-21 10:46:14 +02:00
Valdemar Lorenzen cce3b1e469 feat: show line counts in pull request picker 2026-10-21 10:24:43 +02:00
Valdemar Lorenzen 31203889bb fix: preserve review comments across unrelated edits 2026-10-20 16:28:53 +02:00
Valdemar Lorenzen 2727425518 fix: pad LSP indicators in both bars 2026-10-20 16:28:53 +02:00
Valdemar Lorenzen 33a8c423ac fix: clarify review IDs and CLI guidance 2026-10-20 16:28:53 +02:00
Valdemar Lorenzen 759947736e improve: show whole-diff totals alongside file stats 2026-10-20 16:28:53 +02:00
Valdemar Lorenzen a5a246db77 feat: copy diff review comments to configured clipboard 2026-10-20 16:28:53 +02:00
Valdemar Lorenzen eae3bdba16 fix: clip inline blame instead of hiding long metadata
The full-label width check hid current-line attribution when only part of the commit metadata fit. Render into the remaining cells without covering source text; preserve offscreen and no-space guards.
2026-10-20 16:28:53 +02:00
Valdemar Lorenzen 4fb1be7972 fix: show revision-aware blame in diff buffers
Diff and snapshot sources were excluded before scheduling, and the separate diff renderer never painted attribution. Capture per-side Git provenance, preserve private object stores for historical tabs, and reuse the current-line worker and cache.
2026-10-20 16:28:53 +02:00
Valdemar Lorenzen 8f8d96ead9 fix: key review checkmarks by file snapshots instead of revision pairs 2026-10-20 16:28:53 +02:00
hl-valdemar ea04e30072 feat: persist diff file review checkmarks 2026-10-19 19:04:14 +02:00
Valdemar Lorenzen fe039e75c8 chore: update todo 2026-10-19 16:28:38 +02:00
hl-valdemar b062a79bb5 refactor: normalize review and comment storage 2026-10-18 10:44:48 +02:00
hl-valdemar 3c270ed7b5 feat: delete local review comments from CLI 2026-10-18 10:44:48 +02:00
hl-valdemar a28444a9f2 fix: centralize chrome segment spacing 2026-10-18 01:57:32 +02:00
hl-valdemar d8cfd27ee6 fix: place command status left of battery as a separate segment 2026-10-18 01:42:40 +02:00
hl-valdemar e310b76794 fix: honor configured wrap bindings in shell output 2026-10-18 01:31:35 +02:00
hl-valdemar 074df97c02 feat: toggle wrapping in shell output windows 2026-10-18 01:28:26 +02:00
hl-valdemar 00207b5526 fix: delay shell command indicator to avoid flashes 2026-10-18 01:20:22 +02:00
hl-valdemar a5f92452b3 feat: run shell commands asynchronously from command mode 2026-10-18 00:38:37 +02:00
hl-valdemar f942f881e6 fix: document config-local Ruby lint policy 2026-10-18 00:30:02 +02:00
hl-valdemar 011dd7cc49 fix: correct Ruby linting for Vamp configuration 2026-10-18 00:05:10 +02:00
hl-valdemar 0a01d9a361 feat: cycle command completions backward with Shift+Tab 2026-10-17 18:29:11 +02:00
hl-valdemar 5d75964ee2 feat: cycle command completions with Tab 2026-10-17 18:20:04 +02:00
hl-valdemar 5697a08c0a feat: navigate command and search input history 2026-10-17 01:56:02 +02:00
hl-valdemar 2cb3736b92 fix: save review comments only on explicit write 2026-10-17 01:50:22 +02:00
hl-valdemar e9a757adc5 feat: wrap diff review comments by default 2026-10-17 01:36:41 +02:00
51 changed files with 12490 additions and 1702 deletions
+5
View File
@@ -7,3 +7,8 @@ Style/StringLiterals:
Style/StringLiteralsInInterpolation: Style/StringLiteralsInInterpolation:
EnforcedStyle: double_quotes EnforcedStyle: double_quotes
Metrics/BlockLength:
AllowedMethods:
- refine
- Vamp.configure
Generated
+11
View File
@@ -151,6 +151,16 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "codebook-tree-sitter-typst"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f785ad90ce80edd9e8b3d00890af0bc163e9a2c38f03daa18aede98d343d093"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]] [[package]]
name = "compact_str" name = "compact_str"
version = "0.9.1" version = "0.9.1"
@@ -2003,6 +2013,7 @@ dependencies = [
name = "vamp" name = "vamp"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"codebook-tree-sitter-typst",
"crossterm", "crossterm",
"ignore", "ignore",
"ratatui", "ratatui",
+1
View File
@@ -27,6 +27,7 @@ tree-sitter-python = "0.25.0"
tree-sitter-ruby = "0.23.1" tree-sitter-ruby = "0.23.1"
tree-sitter-rust = "0.24.2" tree-sitter-rust = "0.24.2"
tree-sitter-toml-ng = "0.7.0" tree-sitter-toml-ng = "0.7.0"
tree-sitter-typst = { package = "codebook-tree-sitter-typst", version = "=0.12.1" }
tree-sitter-typescript = "0.23.2" tree-sitter-typescript = "0.23.2"
tree-sitter-yaml = "0.7.2" tree-sitter-yaml = "0.7.2"
tree-sitter-zig = "1.1.2" tree-sitter-zig = "1.1.2"
+14 -2
View File
@@ -13,18 +13,30 @@ cargo run -- --config examples/config.rb notes.txt
Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty buffer. `cargo run -- --help` lists the command-line options. Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty buffer. `cargo run -- --help` lists the command-line options.
### Read local reviews without opening the editor ### Manage local reviews without opening the editor
```sh ```sh
vamp --review list --repo . vamp --review list --repo .
vamp --review list --repo . --json vamp --review list --repo . --json
vamp --review show 1 --repo . --json vamp --review show 1 --repo . --json
vamp --review show 1 --repo . --all vamp --review show 1 --repo . --all
vamp --review delete 1 3 --repo . # Delete comment 3 from review 1
vamp --review resolve 1 3 --repo . # Mark comment 3 resolved, preserving its feedback
``` ```
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.
`show` includes unresolved comments by default; `--all` includes resolved comments too. Human-readable output is the default. JSON includes the canonical repository root, storage review ID/revision, summary/verdict, total/resolved/unresolved counts, and comments with exact zero-based, end-exclusive grapheme anchors and selected source excerpts—not whole source files. `list --json` returns the repository and review metadata/counts without comments. Explicit editor JSON exports still contain complete anchor source text. 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.
`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.
`list` omits reviews with no comments in both text and JSON output. Reviews containing only resolved comments remain listed. Commentless reviews remain stored to preserve file checkmarks, summaries, and verdicts; the editor can still resume them, and `show ID` can inspect them directly.
`list` and `show` are read-only. `delete REVIEW_ID COMMENT_ID` immediately commits deletion of one resolved or unresolved comment. `resolve REVIEW_ID COMMENT_ID` instead marks that comment resolved, preserving its body and original anchor. Repeating resolution succeeds without writing or advancing the review revision. Both commands print the updated review with its remaining unresolved comments (`--json` is supported); use `show REVIEW_ID --all` to verify resolved feedback. Both IDs are required; missing reviews/comments and repository mismatches fail without changing stored reviews or creating missing storage. Deleting the last comment keeps the review, summary, and verdict. Concurrent saves use the same revision-conflict protection as the editor; an already-open editor must reload before saving after a CLI change. Existing JSON exports are not modified.
`vamp review` still opens a file named `review`; `vamp -- --review` opens a file named `--review`. `vamp review` still opens a file named `review`; `vamp -- --review` opens a file named `--review`.
+8 -8
View File
@@ -1,20 +1,20 @@
- [x] syntax highlighting for Honey (honeylang) - [x] improve: diff picker - if jj managed, present as jj log.
- honeylang treesitter project located at `/Users/valdemar/Developer/Personal/Languages/tree-sitter-honey`. - this makes it much easier to determine which revisions to actually pick for before/after
- [x] bug: `x` cut action does not copy the hovered character if not in selection mode, though should always copy. - the bookmarks only view could simply filter out unbookmarked revisions but otherwise display in the same way
- [ ] diff view: GitHub code review integration after the local review UX is usable. - [ ] feature: diff view - GitHub code review integration after the local review UX is usable.
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes. - 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.
+11 -8
View File
@@ -11,14 +11,17 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
- `editor`: owns shared buffer lifecycle/text/saved state/undo history and independent tab/pane layouts, focused views, modes, selections, searches, explorers, and comparisons; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, key-sequence/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected. - `editor`: owns shared buffer lifecycle/text/saved state/undo history and independent tab/pane layouts, focused views, modes, selections, searches, explorers, and comparisons; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, key-sequence/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
- `chrome`: owns validated top/bottom bar anchor groups, segment title templates and padding/edge options, builtin defaults, named-value validation, and typed command/search popup anchors. Both bars accept the same segments and layout options. It has no terminal geometry or render callbacks. - `chrome`: owns validated top/bottom bar anchor groups, segment title templates and padding/edge options, builtin defaults, named-value validation, and typed command/search popup anchors. Both bars accept the same segments and layout options. It has no terminal geometry or render callbacks.
- `battery`: reads native charge off the input thread, sampling immediately and every 30 seconds through an interruptible worker. macOS CoreFoundation references are type-checked and owned Copy/Create values released; Linux reads system battery sysfs and Windows reads native power status. A one-slot atomic mailbox retains the latest sample; absent/invalid readings clear cached presentation instead of inventing a percentage. Session starts collection only while an unoverridden battery segment is configured. - `battery`: reads native charge off the input thread, sampling immediately and every 30 seconds through an interruptible worker. macOS CoreFoundation references are type-checked and owned Copy/Create values released; Linux reads system battery sysfs and Windows reads native power status. A one-slot atomic mailbox retains the latest sample; absent/invalid readings clear cached presentation instead of inventing a percentage. Session starts collection only while an unoverridden battery segment is configured.
- `blame`: owns local current-line Git attribution against the supplied in-memory text and a cancellable background worker. Git-backed jj uses its working-copy parent's commit through an isolated temporary Git store; active refs/checkouts remain untouched. - `shell`: runs explicit `:!` commands off the input thread with inherited environment, closed stdin, and bounded stdout/stderr capture. Each command has a private Unix process group, killed and reaped on worker drop or shell completion. Session owns concurrent workers and combines their delayed spinners in one shared status segment; Editor owns the read-only output popup, queued completed results, and modal scrolling/dismissal state. No repository-command environment overrides or timeouts apply.
- `blame`: owns current-line Git attribution against supplied in-memory text and a cancellable background worker. Historical buffers capture their exact Git store, commit, and side-specific path; private stores remain alive while snapshots or requests reference them. Git-backed jj uses an isolated temporary Git store; active refs/checkouts remain untouched.
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations. - `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
Virtual diff leaves retain their original catalog input index for caller-owned annotations. Normal scanned leaves accept optional repository status and old-path metadata without filtering unchanged files; missing tracked deletions add virtual leaves and required directories. Metadata-only updates preserve query, selection, expansion, and view without rebuilding the catalog. Virtual diff leaves retain their original catalog input index for caller-owned annotations. Normal scanned leaves accept optional repository status and old-path metadata without filtering unchanged files; missing tracked deletions add virtual leaves and required directories. Metadata-only updates preserve query, selection, expansion, and view without rebuilding the catalog.
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies. - `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
- `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. `Store` uses schema-versioned transactions and compare-and-swap review revisions; repository roots and persistent review IDs are independent of comparison revisions. One bounded JSON document is stored per row, retaining exact grapheme endpoints and stale snapshots. Incompatible schemas and concurrent edits fail without replacing data or advancing in-memory saved state. - `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 listing returns validated review documents in descending ID order. CLI metadata, sole-review restoration, and selector labels reuse those documents without reloading them; 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.
`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. 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.
CLI listings exclude reviews with zero comments, not reviews with zero unresolved comments. Filtering stays at the CLI boundary: storage listing and editor restoration retain commentless reviews and their file checkmarks, summaries, and verdicts.
`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.
@@ -56,13 +59,13 @@ Frontends borrow `top_bar_config()` and `bottom_bar_config()` for validated orde
Frontends borrow `popup_config()` for independently configured command/search anchors. The TUI resolves each anchor against the chrome-free workspace using the existing bounded popup size; startup configuration does not alter buffer text, modal input, or notification positioning. Frontends borrow `popup_config()` for independently configured command/search anchors. The TUI resolves each anchor against the chrome-free workspace using the existing bounded popup size; startup configuration does not alter buffer text, modal input, or notification positioning.
Frontends borrow `lsp_label()` / `lsp_names()` for the focused buffer's cached ready-server metadata. Initialization-only worker events trigger status redraws independently of diagnostics; session synchronization clears or replaces names when focus changes or a server retires. `notification_visible()` gates persistent operation/diagnostic popups independently of stored message text, so Escape dismissal neither alters text nor collapses carets. Display-only home abbreviation does not change document identity. Frontends borrow `lsp_label()` / `lsp_names()` for the focused buffer's cached ready-server metadata. Initialization-only worker events trigger status redraws independently of diagnostics; session synchronization clears or replaces names when focus changes or a server retires. `notification_visible()` gates persistent operation/diagnostic popups independently of stored message text, so Escape dismissal neither alters text nor collapses carets. Display-only home abbreviation does not change document identity.
Current-line blame is enabled by default. Session loads whole-file incremental attribution into one active-buffer cache keyed by buffer/text/save revision, disk stamp, and path; cursor, tab, and pane movements within that source reuse it without copying file text or issuing repository commands. Cold results select the current row when they arrive. Edits, saves, source changes, disk refreshes, focus regain, and disabling invalidate attribution; stale results cannot replace a newer source. Metadata is shared per commit with `Arc`, and same-commit navigation reuses the sanitized label. Rendering uses only spare cells after the current logical line's final visible continuation and hides it when the primary caret is offscreen. Disabling drops the worker; comparisons and immutable buffers never schedule live blame. Current-line blame is enabled by default. Session loads whole-file incremental attribution into one active-buffer cache keyed by buffer/text/save revision, disk stamp, path, and optional historical source; cursor, tab, and pane movements within that source reuse it without copying file text or issuing repository commands. Cold results select the current row when they arrive. Edits, saves, source changes, disk refreshes, focus regain, and disabling invalidate attribution; stale results cannot replace a newer source. Comparison provenance follows accepted snapshot/checkout/bookmark refreshes and is retained by immutable buffers independently of their original comparison. Historical queries use a private HEAD and object alternates, never live-file existence or the current checkout baseline. Metadata is shared per commit with `Arc`, and same-commit navigation reuses the sanitized label. Ordinary and split/unified diff rendering use only spare cells after the current logical line's final visible continuation and hide the label when the primary caret is offscreen. Disabling drops the worker.
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion, optional repository status and old path). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors and sanitized rename labels, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned. Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion, optional repository status and old path). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors and sanitized rename labels, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds. Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
Frontends borrow `semantic_tokens()` for sorted, single-line grapheme spans from the active buffer. Immutable `Arc` slices are shared by native LSP and buffer storage; painting does not copy tokens. Text replacements retain tokens outside the changed range and shift following positions using whole graphemes, including newline changes. Tokens overlapping the changed range are dropped; disjoint edits conservatively invalidate the range between their first and last changes. This applies to typing, undo/redo, completion, plugin replacements, and disk reloads. Path changes discard overlays. Native updates validate the whole applicable annotation batch before mutation. The TUI merges semantic styling into its existing glyph pass above syntax foregrounds but below diagnostic/search/selection/caret styling. Frontends borrow `semantic_tokens()` for sorted, single-line grapheme spans from the active buffer. Immutable `Arc` slices are shared by native LSP and buffer storage; painting does not copy tokens. Text replacements retain tokens outside the changed range and shift following positions using whole graphemes, including newline changes. Tokens overlapping the changed range are dropped; disjoint edits conservatively invalidate the range between their first and last changes. This applies to typing, undo/redo, completion, plugin replacements, and disk reloads. Path changes discard overlays. Native updates validate the whole applicable annotation batch before mutation. The TUI merges semantic styling into its existing glyph pass above syntax foregrounds but below diagnostic/search/selection/caret styling.
Frontends inspect `hover()` for optional read-only content and logical-line/grapheme scroll offsets. Popup request eligibility, dismissal, and scrolling stay editor/session-owned; the TUI clips the panel inside the full editor area without changing the primary cursor or viewport. Its hover-local syntax cache treats content as Markdown, including named fenced-code injections, and rebuilds only when content changes. The popup shares the document syntax palette and preserves raw Markdown text and logical scroll coordinates. Frontends inspect `hover()` for optional read-only content and logical-line/grapheme scroll offsets. Popup request eligibility, dismissal, and scrolling stay editor/session-owned; the TUI clips the panel inside the full editor area without changing the primary cursor or viewport. Its hover-local syntax cache treats content as Markdown, including named fenced-code injections, and rebuilds only when content changes. The popup shares the document syntax palette and preserves raw Markdown text and logical scroll coordinates.
Frontends borrow `completion()` for the read-only item list and selected index: zero is Original text, and other indices select `items[selected - 1]`. The editor owns the immutable original document/carets, atomic candidate transactions, cycling/dismissal, pending Insert undo, and final-only repeat recording. It changes only the primary completion target and translates secondary carets through all edits. The TUI places a windowed list near the current caret, bounded by the full editor rather than its pane, sanitizes/clips labels and detail by grapheme, and preserves hardware cursor ownership; wheel input over the list is consumed without dispatching edits. Frontends borrow `completion()` for the read-only item list and selected index: zero is Original text, and other indices select `items[selected - 1]`. The editor owns the immutable original document/carets, atomic candidate transactions, cycling/dismissal, pending Insert undo, and final-only repeat recording. It changes only the primary completion target and translates secondary carets through all edits. The TUI places a windowed list near the current caret, bounded by the full editor rather than its pane, sanitizes/clips labels and detail by grapheme, and preserves logical cursor ownership; wheel input over the list is consumed without dispatching edits.
Frontends borrow `snippet_selections()` for the active argument's end-exclusive grapheme ranges, including mirrors and empty tabstops. The editor caches those ranges after previews, argument edits, and navigation; rendering adds no snippet allocation. Template edits stay primary-only, translate other carets, and fold resolved replacement text and caret placement into the pending completion repeat step. Per-occurrence ancestry distinguishes nested arguments from adjacent empty stops, allowing enclosing replacements to retire only overwritten descendants. Frontends borrow `snippet_selections()` for the active argument's end-exclusive grapheme ranges, including mirrors and empty tabstops. The editor caches those ranges after previews, argument edits, and navigation; rendering adds no snippet allocation. Template edits stay primary-only, translate other carets, and fold resolved replacement text and caret placement into the pending completion repeat step. Per-occurrence ancestry distinguishes nested arguments from adjacent empty stops, allowing enclosing replacements to retire only overwritten descendants.
## Terminal lifecycle and layout ## Terminal lifecycle and layout
@@ -71,13 +74,13 @@ The terminal guard owns raw mode, alternate-screen state, bracketed paste, mouse
`tui::TerminalSession::run(inherited_screen, body)` scopes this ownership; `tui::run(&mut session, inherited_screen)` renders inside that active guard. On Unix, a fresh alternate screen pushes disambiguated keyboard flags so Ctrl+i stays distinct from Tab; final cleanup pops them. `TerminalSession::prepare_restart()` resets process-local input state without leaving the alternate screen or popping its keyboard flags. The replacement process inherits that stack entry rather than pushing another, and the guard stays armed if relaunch fails. `tui::TerminalSession::run(inherited_screen, body)` scopes this ownership; `tui::run(&mut session, inherited_screen)` renders inside that active guard. On Unix, a fresh alternate screen pushes disambiguated keyboard flags so Ctrl+i stays distinct from Tab; final cleanup pops them. `TerminalSession::prepare_restart()` resets process-local input state without leaving the alternate screen or popping its keyboard flags. The replacement process inherits that stack entry rather than pushing another, and the guard stays armed if relaunch fails.
Redraws stage Ratatui's buffer diff, cursor placement, and DEC synchronized-update commands (mode 2026) in a reusable byte buffer, then write and flush the complete frame to the terminal. Frame computation never suspends terminal presentation; synchronization covers transmitted painting rather than layout/highlighting work. The hardware cursor is hidden before painting even when synchronization is unsupported. Terminal cleanup releases synchronization before leaving the alternate screen or invoking caller panic diagnostics. Backend-writer access is enabled solely for this staging seam. Redraws stage Ratatui's buffer diff, logical cursor placement, and DEC synchronized-update commands (mode 2026) in a reusable byte buffer, then write and flush the complete frame to the terminal. Frame computation never suspends terminal presentation; synchronization covers transmitted painting rather than layout/highlighting work. `WheelPanels::cursor` records the final focused cursor position; the software cursor is painted after overlays, using the same named ANSI mode palette as the bars. `SoftwareCursorBackend` delegates drawing and cursor coordinates but suppresses hardware-cursor visibility, retaining input-method placement without a second cursor or OSC 12 dependency. The hardware cursor stays hidden even when synchronization is unsupported. Terminal cleanup releases synchronization before leaving the alternate screen, then restores cursor visibility before returning or invoking caller panic diagnostics. Backend-writer access is enabled solely for this staging seam.
`terminal_colors` intercepts crossterm's key-shaped OSC 11/4 replies before editor dispatch, assembles a bounded RGB background/red/green triple, and preserves unrelated events and opaque bracketed paste. Queries never block input and start after painting, so syntax initialization does not spend their reply deadline. Startup, color-scheme notifications, and focus-regain queries replace cached diff tints atomically. While any rendered pane shows a diff, a one-second fallback probes for silent palette changes or missed notifications; ordinary editor views do not poll. Its injected-time deadline prevents overlapping periodic requests. An unanswered/partial query expires after 250 ms and removes stale tints rather than assuming a dark theme. Valid late replies are consumed without becoming editing commands. The frontend precomputes rounded RGB blends from the original background (18% row, 38% character), propagating them to active, parked, and newly created panes. `terminal_colors` intercepts crossterm's key-shaped OSC 11/4 replies before editor dispatch, assembles a bounded RGB background/red/green triple, and preserves unrelated events and opaque bracketed paste. Queries never block input and start after painting, so syntax initialization does not spend their reply deadline. Startup, color-scheme notifications, and focus-regain queries replace cached diff tints atomically. While any rendered pane shows a diff, a one-second fallback probes for silent palette changes or missed notifications; ordinary editor views do not poll. Its injected-time deadline prevents overlapping periodic requests. An unanswered/partial query expires after 250 ms and removes stale tints rather than assuming a dark theme. Valid late replies are consumed without becoming editing commands. The frontend precomputes rounded RGB blends from the original background (18% row, 38% character), propagating them to active, parked, and newly created panes.
`vendor/crossterm` retains Crossterm 0.29.0's upstream source and MIT license, with a small Unix decoder patch exposing `Event::ColorSchemeChanged` for `CSI ? 997 ; 1/2 n`. The report signals a palette refresh, not a choice of hard-coded light/dark colors. Valid fragmented reports and malformed color reports leave following keys intact. Cargo patches both direct and Ratatui dependency paths to this one copy; remove the patch/workspace member once upstream supports these notifications. `vendor/crossterm` retains Crossterm 0.29.0's upstream source and MIT license, with a small Unix decoder patch exposing `Event::ColorSchemeChanged` for `CSI ? 997 ; 1/2 n`. The report signals a palette refresh, not a choice of hard-coded light/dark colors. Valid fragmented reports and malformed color reports leave following keys intact. Cargo patches both direct and Ratatui dependency paths to this one copy; remove the patch/workspace member once upstream supports these notifications.
The TUI allocates a flexible buffer viewport and optional one-row top/bottom bars, with no reserved prompt/message footer. Both bars derive visibility from configured content: empty content takes no row, and tabs-only content is hidden with a single tab in either position. Both render across ordinary, split, diff, and selector views. Ordered left/center/right groups use full-row centering, active-tab collision priority, internal padding, and independent styled edges. Floating Command/Search input owns the hardware cursor; non-focusable operation/diagnostic notifications anchor bottom-right inside the chrome-free workspace and hide obscured document carets. Higher-priority modal overlays suppress them without changing input state. Wheel hit regions clear each frame and consume reports over these popups. Zero-sized terminals render nothing; minimal-height input never reserves another row. Layout and terminal-cell text mapping remain frontend-owned. The TUI allocates a flexible buffer viewport and optional one-row top/bottom bars, with no reserved prompt/message footer. Both bars derive visibility from configured content: empty content takes no row, and tabs-only content is hidden with a single tab in either position. Both render across ordinary, split, diff, and selector views. Ordered left/center/right groups use full-row centering, active-tab collision priority, internal padding, and independent styled edges; accented content and edges share the focused mode's ANSI palette colors. Floating Command/Search input owns the software cursor; non-focusable operation/diagnostic notifications anchor bottom-right inside the chrome-free workspace and hide obscured document carets. Higher-priority modal overlays suppress them without changing input state. Wheel hit regions clear each frame and consume reports over these popups. Zero-sized terminals render nothing; minimal-height input never reserves another row. Layout and terminal-cell text mapping remain frontend-owned.
Comparison views reserve no separate metadata header or native-buffer help footer. Revision labels render bottom-left in pane borders (combined on single-pane views), with per-side editability top-right and focus indicated by border styling. Dock and document geometry use the reclaimed workspace rows; conditional loading/error/file notices remain separate from the border metadata. Comparison views reserve no separate metadata header or native-buffer help footer. Revision labels render bottom-left in pane borders (combined on single-pane views), with per-side editability top-right and focus indicated by border styling. Dock and document geometry use the reclaimed workspace rows; conditional loading/error/file notices remain separate from the border metadata.
+84 -21
View File
@@ -15,6 +15,8 @@ Vamp.configure do |config|
config.clipboard_mode = "system" config.clipboard_mode = "system"
config.git_blame = true # false disables current-line attribution config.git_blame = true # false disables current-line attribution
config.git_gutter = "bar" # or "symbols", "line_numbers", "none" config.git_gutter = "bar" # or "symbols", "line_numbers", "none"
config.cursor = {} # mode colors enabled; all modes use a block cursor
# config.cursor = { mode_colors: false, insert: :bar, visual: :underline }
config.scroll_distance = 0 # nonnegative edge distance, or nil to center config.scroll_distance = 0 # nonnegative edge distance, or nil to center
config.scroll_step_lines = nil # Ctrl+u/d: half viewport, or a positive screen-row count config.scroll_step_lines = nil # Ctrl+u/d: half viewport, or a positive screen-row count
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
@@ -30,6 +32,8 @@ Vamp.configure do |config|
config.diff_view_mode = "split" # or "unified" config.diff_view_mode = "split" # or "unified"
config.diff_split_orientation = "vertical" # or "horizontal" config.diff_split_orientation = "vertical" # or "horizontal"
config.diff_follow_working_copy = true config.diff_follow_working_copy = true
config.pull_request_review_requested_symbol = "🙏"
config.pull_request_approved_symbol = "👍"
config.resolve_history_layout = "columns" # or "base_below" config.resolve_history_layout = "columns" # or "base_below"
config.review_directory = nil # XDG data directory; override with an absolute or config-relative path config.review_directory = nil # XDG data directory; override with an absolute or config-relative path
config.bind "ctrl+h", config.core.left, mode: config.modes.tree config.bind "ctrl+h", config.core.left, mode: config.modes.tree
@@ -56,6 +60,12 @@ Plugin paths resolve relative to the config file, not the shell's working direct
`config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors. `config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
`config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration. `config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration.
`config.cursor` accepts a partial hash. `mode_colors` defaults to `true`: the primary software cursor and secondary text carets use the terminal's ANSI cyan in Normal, green in Insert, magenta in Visual (including Visual Line), yellow in Command, and blue in Search. These are palette entries from the current terminal color scheme, not fixed RGB colors. Set `config.cursor = { mode_colors: false }` for a neutral reverse-video block cursor (default text color for bar/underline); secondary carets retain their magenta highlight.
The `normal`, `insert`, `visual`, `command`, and `search` keys select software cursor shapes: `:block`, `:bar`, or `:underline` (strings also work). Every mode defaults to a steady block. Block and underline preserve the displayed grapheme; the bar draws `▏` over the hovered character until the cursor moves. For example, `config.cursor = { insert: :bar, visual: :underline }` changes those two shapes without disabling mode colors. Each assignment replaces the cursor configuration; omitted keys retain built-in defaults. Unknown keys, nonboolean `mode_colors`, unsupported shapes, and non-hash values reject the configuration.
Shapes apply to the primary cursor, including command/search prompts, pickers, and historical/diff text; secondary carets remain cell highlights. The cursor is painted after the focused view's overlays, takes precedence over syntax/search/selection colors, and disappears when its position is offscreen or covered by a notification. The hardware cursor stays hidden during editing; its logical position is retained for input methods, and normal exit/errors/panics restore its visibility. Vamp does not change terminal cursor color or shape settings, so multiplexers need no native cursor-color support. File contents and cursor positions remain unchanged.
`config.scroll_distance` defaults to `0`: keep the viewport still until its primary cursor or selected picker entry would leave it. A nonnegative integer reserves that many visible rows above/below the cursor, and terminal-cell columns left/right in unwrapped text. `nil` keeps it centered; integers at least half the current viewport dimension behave the same way until resizing makes that margin usable. The start of content limits centering, while trailing blank space allows centering near the end. Whole two-row picker entries stay aligned, using the nearest entry to the center when exact margins cannot fit. `config.scroll_distance` defaults to `0`: keep the viewport still until its primary cursor or selected picker entry would leave it. A nonnegative integer reserves that many visible rows above/below the cursor, and terminal-cell columns left/right in unwrapped text. `nil` keeps it centered; integers at least half the current viewport dimension behave the same way until resizing makes that margin usable. The start of content limits centering, while trailing blank space allows centering near the end. Whole two-row picker entries stay aligned, using the nearest entry to the center when exact margins cannot fit.
The setting applies to ordinary and historical buffers, split/unified diff panes, file pickers and explorers, command/buffer palettes, completion lists, and revision selectors. Wrapped text measures visual rows, not source lines; picker distances use result rows, excluding borders, search fields, and help. Manual mouse-wheel scrolling remains detached until keyboard navigation resumes. Only `nil` or integers from `0` through the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems) are accepted; other values reject the configuration. The setting applies to ordinary and historical buffers, split/unified diff panes, file pickers and explorers, command/buffer palettes, completion lists, and revision selectors. Wrapped text measures visual rows, not source lines; picker distances use result rows, excluding borders, search fields, and help. Manual mouse-wheel scrolling remains detached until keyboard navigation resumes. Only `nil` or integers from `0` through the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems) are accepted; other values reject the configuration.
@@ -103,7 +113,23 @@ Indicators compare the current editor text (including unsaved edits) against Git
`config.diff_view_mode` selects the initial view for each new comparison: `"split"` (default) or `"unified"`. `config.diff_split_orientation` selects `"vertical"` (left/right, default) or `"horizontal"` (above/below) for split views. Invalid values reject the configuration. Normal-mode **Space v** cycles split/unified; **Space s** flips split orientation. These controls affect the current comparison, not startup defaults, and retain their choices across file switches and tabs. Orientation changes apply only in split view. `config.diff_view_mode` selects the initial view for each new comparison: `"split"` (default) or `"unified"`. `config.diff_split_orientation` selects `"vertical"` (left/right, default) or `"horizontal"` (above/below) for split views. Invalid values reject the configuration. Normal-mode **Space v** cycles split/unified; **Space s** flips split orientation. These controls affect the current comparison, not startup defaults, and retain their choices across file switches and tabs. Orientation changes apply only in split view.
`config.diff_follow_working_copy` defaults to `true`. A Working-checkout After follows descendant checkouts automatically, including in inactive tabs; `false` disables endpoint following without disabling ordinary live-buffer editing or reload. Only literal booleans are accepted. Pinned After endpoints never move, but their checkout-aware editability checks remain enabled regardless of this setting. See [repository diff](editing.md#repository-diff) for ancestry and refresh behavior. `config.diff_follow_working_copy` defaults to `true`. The After picker calls its live-files choice **Follow checkout** when enabled, otherwise **Checkout snapshot**. Following advances to descendant checkouts automatically, including in inactive tabs; `false` disables endpoint following without disabling ordinary live-buffer editing or reload. Only literal booleans are accepted. Before is always frozen. Pinned After endpoints never move, but their checkout-aware editability checks remain enabled regardless of this setting. See [repository diff](editing.md#repository-diff) for ancestry and refresh behavior.
`config.pull_request_review_requested_symbol` and `config.pull_request_approved_symbol` control the markers before PR numbers in the pull-request picker. Defaults are `"🙏"` for an outstanding personal review request and `"👍"` for your active approval, using the API-authenticated account. Requests take precedence over approvals when both apply. Each option accepts a string without control characters; multi-character and wide Unicode symbols are supported. Set either to `""` to hide that state, or both to `""` to remove the marker gutter. PR numbers and metadata stay aligned by terminal-cell width, including rows without a marker. Non-strings and strings containing tabs, newlines, or other control characters reject the configuration.
`config.pull_request_sort` optionally overrides saved sorting: `nil` or an omitted option uses saved sorting, then built-in defaults. A configured sort section replaces that entire saved section; omitted criteria use built-in defaults. `config.pull_request_filters` sets the session's initial filter buckets; `nil` or omission includes all buckets. UI filter changes are shared across tabs and picker reopenings for this vamp session only. Restart restores the configured filters or includes all buckets; previously saved filters are ignored.
```ruby
config.pull_request_sort = {
updated_at: { precedence: 1, direction: :desc },
created_at: { direction: :asc }
}
config.pull_request_filters = [:requested, :approved]
```
Sort fields are `requested`, `updated_at`, `approved`, and `created_at`, in that default precedence order. Default direction is `desc`; timestamp criteria support `asc` for oldest first. `precedence` is `nil` (unnumbered default position) or a unique integer in `1..4`. Filters are any subset of `requested` (Requested from me), `approved` (Approved by me without an outstanding personal request), and `other` (Other / unavailable). `[]` excludes all requests; `{}` resets sorting to built-in defaults. Unknown fields/options, duplicate ranks/filters, and invalid types or directions reject configuration.
Enter Apply in the sort menu saves changed sorting to `$XDG_STATE_HOME/vamp/pull_requests.json`, or `~/.local/state/vamp/pull_requests.json` if XDG state is unset or relative. Applying filters never writes this file, and subsequent sort saves exclude filters. This also works with `--no-config`. Configuration remains a startup override, not a lock: UI edits work for the session, but explicitly configured settings win again next startup. Sort save failures report session-only changes. Corrupt, incompatible, or non-regular storage is reported and preserved rather than reset or overwritten.
`config.resolve_history_layout` controls the read-only conflict inputs opened by `:resolve.history`: `"columns"` (default) places left/base/right side by side; `"base_below"` places left/right above a full-width base. Both layouts share aligned rows and synchronized vertical scrolling. Tab/Shift+Tab follows layout order (left/base/right or left/right/base); directional pane actions follow the displayed geometry. Narrow terminals show the focused source only; the base-below layout does the same when there is insufficient height. This setting does not change the editable Resolution view or ordinary diffs. Invalid values reject the configuration. `config.resolve_history_layout` controls the read-only conflict inputs opened by `:resolve.history`: `"columns"` (default) places left/base/right side by side; `"base_below"` places left/right above a full-width base. Both layouts share aligned rows and synchronized vertical scrolling. Tab/Shift+Tab follows layout order (left/base/right or left/right/base); directional pane actions follow the displayed geometry. Narrow terminals show the focused source only; the base-below layout does the same when there is insufficient height. This setting does not change the editable Resolution view or ordinary diffs. Invalid values reject the configuration.
@@ -117,9 +143,13 @@ config.bind "leader s", config.core.diff.orientation.toggle
`config.bar.top` and `config.bar.bottom` each accept ordered `left`, `center`, and `right` arrays of segment names. Both bars accept every builtin and custom segment, with the same anchors, title templates, margins, padding, styled edges, and collision handling. Left and right groups anchor to their respective edges; the center group is centered on the whole row, not on the gap remaining between the other groups. Array order is display order within each group. Omitted groups and segment options retain that bar's defaults; an empty array removes that group. `config.bar.top` and `config.bar.bottom` each accept ordered `left`, `center`, and `right` arrays of segment names. Both bars accept every builtin and custom segment, with the same anchors, title templates, margins, padding, styled edges, and collision handling. Left and right groups anchor to their respective edges; the center group is centered on the whole row, not on the gap remaining between the other groups. Array order is display order within each group. Omitted groups and segment options retain that bar's defaults; an empty array removes that group.
Accented segments (including active tabs, mode, position, and custom labels), their padding, and decorative edge colors follow the focused editing mode using the same ANSI palette as the software cursor. Both bars change together; inactive tabs, neutral text, separators, and margins retain their existing styling. Setting `config.cursor = { mode_colors: false }` affects cursors only, not the bars.
The renderer inserts one unstyled blank column between visible items in each group, including tab titles. Hidden items contribute no separator; there is no separator before the first or after the last item. Separator widths count toward anchoring and collision handling. Plain-text segments default to no padding or margins, so adjacent labels do not double their spacing.
Use the two setters independently; a combined `config.bar = { ... }` assignment is not supported. Use the two setters independently; a combined `config.bar = { ... }` assignment is not supported.
Defaults put `left: [:tabs]` and `right: [:battery]` in the top bar; the bottom has `left: [:mode, :pending, :buffer, :diagnostics, :path, :selections, :count]` and `right: [:language, :lsp, :position]`; other groups are empty. Set the bars independently, as in this swapped layout with status information above and tabs plus live battery charge below: Defaults put `left: [:tabs]` and `right: [:diff, :shell, :battery]` in the top bar; the bottom has `left: [:mode, :pending, :buffer, :diagnostics, :path, :selections, :count]` and `right: [:language, :lsp, :position]`; other groups are empty. Set the bars independently, as in this swapped layout with status information above and tabs plus diff totals, live battery charge, and shell status below:
```ruby ```ruby
Vamp.configure do |config| Vamp.configure do |config|
@@ -127,12 +157,12 @@ Vamp.configure do |config|
left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer], left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer],
center: [], right: [:language, :lsp, :position], center: [], right: [:language, :lsp, :position],
mode: { padding: 1, leading_edge: "", trailing_edge: "▓░" }, mode: { padding: 1, leading_edge: "", trailing_edge: "▓░" },
position: { padding: 1, leading_margin: 1, trailing_margin: 0, leading_edge: "░▓", trailing_edge: "" } position: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
} }
config.bar.bottom = { config.bar.bottom = {
left: [:tabs], center: [], right: [:battery], left: [:tabs], center: [], right: [:diff, :shell, :battery],
tabs: { tabs: {
title: "{index} {view}{name}{modified}", padding: 1, title: "{index} {view}{name}{changes}{modified}", padding: 1,
leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: "" leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: ""
}, },
battery: { padding: 1, leading_edge: "░▓", trailing_edge: "" } battery: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
@@ -140,19 +170,19 @@ Vamp.configure do |config|
end end
``` ```
Builtins are `:tabs`, `:battery`, `:mode`, `:path`, `:position`, `:language`, `:lsp`, `:diagnostics`, `:selections`, `:count`, `:pending`, and `:buffer`, in either bar. They provide tab titles, battery charge, mode, file path/dirty state, caret position, detected language, ready LSP server names, diagnostics, selection count, numeric prefix, pending key sequence, and buffer identity/count. Builtins are `:tabs`, `:diff`, `:battery`, `:shell`, `:mode`, `:path`, `:position`, `:language`, `:lsp`, `:diagnostics`, `:selections`, `:count`, `:pending`, and `:buffer`, in either bar. They provide tab titles, whole-comparison line counts, battery charge, running shell command/spinner, mode, file path/dirty state, caret position, detected language, ready LSP server names, diagnostics, selection count, numeric prefix, pending key sequence, and buffer identity/count.
A bar with no visible content consumes no row. For example, `config.bar.top = { left: [], center: [], right: [] }` removes the top bar. A bar whose only visible content is `:tabs` is hidden with a single tab, whether above or below the workspace. Narrow layouts first omit inactive tabs, then optional segments from group ends to avoid overlap; the active tab drops decorations before its title is clipped. Commands and buffer searches use floating input; operation feedback uses a bottom-right popup. Neither reserves a footer row. A bar with no visible content consumes no row. For example, `config.bar.top = { left: [], center: [], right: [] }` removes the top bar. A bar whose only visible content is `:tabs` is hidden with a single tab, whether above or below the workspace. Narrow layouts first omit inactive tabs, then optional segments from group ends to avoid overlap; the active tab drops decorations before its title is clipped. Commands and buffer searches use floating input; operation feedback uses a bottom-right popup. Neither reserves a footer row.
Each segment's `title` is a literal template supporting only `{index}`, `{view}`, `{name}`, `{path}`, and `{modified}`: the one-based tab index, comparison-view marker, filename, file path, and dirty marker (`" [+]"` when dirty, `""` when clean). `{view}` expands to `"Diff · +N -M · "` for a loaded comparison file, counting added and removed lines; revision selectors and files still loading use `"Diff · "`, and ordinary tabs use `""`. Counts follow the selected file and live edits, including inactive tabs. Tabs use each tab's view and buffer metadata; other segments use the active context. Tabs default to `"{index} {view}{name}{modified}"`; custom templates without `{view}` retain their output. An empty title on another segment uses its builtin content or named label value. Templates are not Ruby code and never invoke a render callback. Each segment's `title` is a literal template supporting only `{index}`, `{view}`, `{name}`, `{path}`, `{changes}`, and `{modified}`: the one-based tab index, comparison-view marker, filename, file path, selected-file line counts, and dirty marker (`" [+]"` when dirty, `""` when clean). `{view}` expands to `"Diff · "` for comparisons and revision selectors, or `""` for ordinary tabs. `{changes}` expands to `" +N -M"` for a loaded comparison file and `""` otherwise. Counts follow the selected file and live edits, including inactive tabs. Tabs use each tab's view and buffer metadata; other segments use the active context. Tabs default to `"{index} {view}{name}{changes}{modified}"`; custom templates must include `{changes}` to display per-file counts. An empty title on another segment uses its builtin content or named label value. Templates are not Ruby code and never invoke a render callback.
`:path` and `{path}` abbreviate the actual home-directory prefix to `~/` (`~` for the home directory itself). This is display-only: save paths, buffer identity, and LSP file URIs retain the original path. Sibling directories with similar names are not abbreviated. `:path` and `{path}` abbreviate the actual home-directory prefix to `~/` (`~` for the home directory itself). This is display-only: save paths, buffer identity, and LSP file URIs retain the original path. Sibling directories with similar names are not abbreviated.
`:lsp` displays only ready servers assigned to the focused buffer, in configured stack order joined with ` + `. Disabled, initializing, failed, or unassigned servers do not appear. Names come from the initialization reply's `serverInfo.name`, falling back to the configured executable basename; no arbitrary named label can make an inactive server appear active. `:lsp` displays only ready servers assigned to the focused buffer, in configured stack order joined with ` + `. Disabled, initializing, failed, or unassigned servers do not appear. Names come from the initialization reply's `serverInfo.name`, falling back to the configured executable basename; no arbitrary named label can make an inactive server appear active.
`margin` adds unstyled space outside both edges of a segment, unlike the styled internal `padding`. `leading_margin` and `trailing_margin` override their respective sides. All accept integers from `0` through `64`. Margins default to zero except the bottom bar's `:position`, which has one leading column and no trailing margin. LSP has no default margins. For example, `config.bar.bottom = { position: { leading_margin: 0 } }` removes position's external spacing without changing its padding. Margins count toward anchor placement and collision handling; empty segments consume no margin, and margins yield along with padding and edges when only clipped content fits. `margin` adds unstyled space outside both edges of a segment, unlike the styled internal `padding`. `leading_margin` and `trailing_margin` override their respective sides. All accept integers from `0` through `64`; all margins default to zero. Explicit margins are additional to the renderer's one-column separator, so leave them at zero for normal inter-item spacing. Margins count toward anchor placement and collision handling; empty segments consume no margin, and margins yield along with padding and edges when only clipped content fits.
`padding` adds spaces inside the segment's styling, on both sides of its title, and accepts integers from `0` through `64` (default `1`). `leading_edge` and `trailing_edge` are separate styled glyph strings, not padding; `""` removes an edge. Tabs default to `"░▓"` / `"▓░"` edges; mode has only the trailing `"▓░"` edge, position and battery only the leading `"░▓"` edge, and other segments have none. `first_leading_edge` optionally overrides the first occurrence's leading glyphs: for tabs this is the actual first tab, not the first tab remaining after clipping; other segments have one occurrence. Tabs default this override to `""`, suppressing the first tab's left fade. Unicode widths are measured in terminal cells. Edge strings must be single-line and contain no control characters. Unknown style keys, invalid templates, and wrong types reject configuration. `padding` adds spaces inside the segment's styling, on both sides of its title, and accepts integers from `0` through `64`. It defaults to `0` for plain-text and custom segments and `1` for tabs, mode, position, battery, shell status, and LSP indicators. Diff totals use plain text without a filled background, padding, or edges by default. Internal padding is separate from the inter-item separator; explicit padding can deliberately add more whitespace. `leading_edge` and `trailing_edge` are separate styled glyph strings, not padding; `""` removes an edge. Tabs and shell status default to `"░▓"` / `"▓░"` edges; mode has only the trailing `"▓░"` edge, position and battery only the leading `"░▓"` edge, and other segments have none. `first_leading_edge` optionally overrides the first occurrence's leading glyphs: for tabs this is the actual first tab, not the first tab remaining after clipping; other segments have one occurrence. Tabs default this override to `""`, suppressing the first tab's left fade. Unicode widths are measured in terminal cells. Edge strings must be single-line and contain no control characters. Unknown style keys, invalid templates, and wrong types reject configuration.
Any other segment name is a custom named value, such as `:build_status`. Set initial strings with `config.labels`; an empty value hides the custom segment, including its title override, margins, padding, and edges. Names match `[A-Za-z_][A-Za-z0-9_]*`. Labels are plain text: control characters are replaced rather than interpreted as terminal escapes. The same named value can appear in either bar. Any other segment name is a custom named value, such as `:build_status`. Set initial strings with `config.labels`; an empty value hides the custom segment, including its title override, margins, padding, and edges. Names match `[A-Za-z_][A-Za-z0-9_]*`. Labels are plain text: control characters are replaced rather than interpreted as terminal escapes. The same named value can appear in either bar.
@@ -160,6 +190,10 @@ Any other segment name is a custom named value, such as `:build_status`. Set ini
Move `:battery` to any anchor group or remove it from both bars to stop battery collection. A nonempty `config.labels[:battery]` or plugin value explicitly overrides the native text and pauses collection; clearing that value restores native readings. Other custom live values use [`editor.label(name, text)` or asynchronous plugin updates](plugins.md#named-label-updates). Configuration runs once at startup; there are no render callbacks. Move `:battery` to any anchor group or remove it from both bars to stop battery collection. A nonempty `config.labels[:battery]` or plugin value explicitly overrides the native text and pauses collection; clearing that value restores native readings. Other custom live values use [`editor.label(name, text)` or asynchronous plugin updates](plugins.md#named-label-updates). Configuration runs once at startup; there are no render callbacks.
`:shell` is one shared segment containing the abbreviations and animated spinners for all running `:!COMMAND` processes. Each entry appears only after its command has run for 200 ms, avoiding flashes for short commands. Completed output is shown immediately or queued behind the current result window. The segment hides when idle. It defaults immediately left of `:battery`, with its own styled edges and the standard one-column separator; move it to any anchor group or omit it without affecting command execution. A nonempty label named `shell` overrides the builtin display; clearing it restores the running indicators.
`:diff` displays `Diff +N -M` for the active comparison, including unopened and filtered-out files, and hides in revision selectors and ordinary tabs. Live edits and undo/redo update totals. An empty comparison shows `Diff +0 -0`; incomplete statistics show `Diff …`, never a partial total. It defaults left of shell status and battery; move it to either bar or omit it like any builtin. See [counting semantics](editing.md#repository-diff).
### Command and search popup anchors ### Command and search popup anchors
Configure the two input windows independently: Configure the two input windows independently:
@@ -199,6 +233,8 @@ Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i` to `config.co
`config.core.backspace_word` and `config.core.backspace_line` are typed actions eligible in Insert, Command, and Search. Ctrl+u defaults to `config.core.backspace_line` in all three modes on every platform; it can be rebound or disabled with the ordinary per-mode binding interface. macOS additionally defaults Option+Backspace and Command+Backspace to word and line deletion respectively. Any platform can explicitly bind these actions to supported keys; for example, `config.bind "ctrl+w", config.core.backspace_word, mode: config.modes.insert`. Word deletion follows Unicode word boundaries after skipping trailing whitespace; line deletion removes the caret-to-logical-line-start prefix, not the suffix or newline. Active snippets confine both actions to their argument. macOS prompt and filter queries also recognize these modified Backspace keys. `config.core.backspace_word` and `config.core.backspace_line` are typed actions eligible in Insert, Command, and Search. Ctrl+u defaults to `config.core.backspace_line` in all three modes on every platform; it can be rebound or disabled with the ordinary per-mode binding interface. macOS additionally defaults Option+Backspace and Command+Backspace to word and line deletion respectively. Any platform can explicitly bind these actions to supported keys; for example, `config.bind "ctrl+w", config.core.backspace_word, mode: config.modes.insert`. Word deletion follows Unicode word boundaries after skipping trailing whitespace; line deletion removes the caret-to-logical-line-start prefix, not the suffix or newline. Active snippets confine both actions to their argument. macOS prompt and filter queries also recognize these modified Backspace keys.
Command-mode Tab defaults to `config.core.command_complete`, and Shift+Tab to `config.core.command_complete_previous`: complete a case-sensitive command-name prefix, then cycle alphabetical matches forward/backward with wraparound. Shift+Tab starts at the last match; switching direction retains the original prefix. Core commands, shorthand aliases, and enabled plugin commands share the candidate list; arguments are not completed. For example, `config.bind "ctrl+n", config.core.command_complete, mode: config.modes.command` adds another forward completion key. Override or disable Tab and Shift+Tab independently; Search-mode Tab remains literal text.
Configuration accepts the Option/Alt and Command/Super Backspace names on every platform, but terminal modifier-event translation for these keys is macOS-only. Native modifier events must deliver Option or Super (Command in Kitty CSI-u); Meta is not Command. Ghostty's default Command+Backspace encoding is Ctrl+u (`\x15`), which invokes the same line-deletion action without a Ghostty override. It remains a distinct Ctrl+u binding, not a reconstructed Command modifier. Legacy ESC+DEL supports Option+Backspace without keyboard protocol negotiation. Configuration accepts the Option/Alt and Command/Super Backspace names on every platform, but terminal modifier-event translation for these keys is macOS-only. Native modifier events must deliver Option or Super (Command in Kitty CSI-u); Meta is not Command. Ghostty's default Command+Backspace encoding is Ctrl+u (`\x15`), which invokes the same line-deletion action without a Ghostty override. It remains a distinct Ctrl+u binding, not a reconstructed Command modifier. Legacy ESC+DEL supports Option+Backspace without keyboard protocol negotiation.
Normal mode uses literal `_` and `|` for above/below and left/right splits, and `alt+h/j/k/l` for directional pane focus. Bind the produced punctuation rather than a physical Shift combination. Close actions have no default binding; for example, `config.bind "leader x", config.core.pane.close`. See [panes and splits](editing.md#panes-and-splits) for shared-buffer and unsaved-change policies. Normal mode uses literal `_` and `|` for above/below and left/right splits, and `alt+h/j/k/l` for directional pane focus. Bind the produced punctuation rather than a physical Shift combination. Close actions have no default binding; for example, `config.bind "leader x", config.core.pane.close`. See [panes and splits](editing.md#panes-and-splits) for shared-buffer and unsaved-change policies.
@@ -225,6 +261,7 @@ Normal and Visual Ctrl+u/Ctrl+d invoke `config.core.scroll_step_up` / `config.co
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` | | Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
| Normal | `join_lines`, `undo`, `redo` | | Normal | `join_lines`, `undo`, `redo` |
| Normal, Visual | `command` | | Normal, Visual | `command` |
| Command | `command_complete`, `command_complete_previous` |
| Normal | `copy_line`, `delete_line`, `change_line` | | Normal | `copy_line`, `delete_line`, `change_line` |
| Normal | `change_to_line_end`, `delete_to_line_end`, `copy_to_line_end`, `substitute_character`, `delete_word_forward`, `delete_word_backward`, `change_word_forward`, `change_word_backward` | | Normal | `change_to_line_end`, `delete_to_line_end`, `copy_to_line_end`, `substitute_character`, `delete_word_forward`, `delete_word_backward`, `change_word_forward`, `change_word_backward` |
| Normal, Visual | `replace_character` | | Normal, Visual | `replace_character` |
@@ -232,8 +269,8 @@ Normal and Visual Ctrl+u/Ctrl+d invoke `config.core.scroll_step_up` / `config.co
| Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` | | Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` |
| Normal | `follow_link` | | Normal | `follow_link` |
| Normal | `diff`, `diff.mode.toggle`, `diff.view.toggle`, `diff.orientation.toggle`, `diff.review.toggle` | | Normal | `diff`, `diff.mode.toggle`, `diff.view.toggle`, `diff.orientation.toggle`, `diff.review.toggle` |
| Normal, Visual | `diff.comment` | | Normal, Visual | `comment` |
| Normal | `diff.review.edit`, `diff.review.delete`, `diff.review.resolve`, `diff.review.unresolve`, `diff.review.next`, `diff.review.previous`, `diff.review.overview`, `diff.review.summary`, `diff.review.verdict`, `diff.review.save`, `diff.review.open`, `diff.review.new` | | Normal | `review.edit`, `review.delete`, `review.resolve`, `review.unresolve`, `review.next`, `review.previous`, `review.overview`, `review.copy`, `review.summary`, `review.verdict`, `review.save`, `review.open`, `review.new` |
| Normal, Insert, Visual, Command, Search | `diff.close` | | Normal, Insert, Visual, Command, Search | `diff.close` |
| Normal | `resolve`, `resolve.next`, `resolve.previous`, `resolve.use_source`, `resolve.source`, `resolve.base`, `resolve.next_file`, `resolve.previous_file`, `resolve.files`, `resolve.draft`, `resolve.history`, `resolve.view.toggle`, `resolve.close` | | Normal | `resolve`, `resolve.next`, `resolve.previous`, `resolve.use_source`, `resolve.source`, `resolve.base`, `resolve.next_file`, `resolve.previous_file`, `resolve.files`, `resolve.draft`, `resolve.history`, `resolve.view.toggle`, `resolve.close` |
| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` | | Normal, Insert, Visual, Command, Search | `word_wrap.toggle` |
@@ -253,7 +290,7 @@ Normal and Visual Ctrl+u/Ctrl+d invoke `config.core.scroll_step_up` / `config.co
| Normal, Visual | `scroll_up`, `scroll_down`, `scroll_step_up`, `scroll_step_down` | | Normal, Visual | `scroll_up`, `scroll_down`, `scroll_step_up`, `scroll_step_down` |
| Normal, Insert, Visual | `line_start`, `line_end` | | Normal, Insert, Visual | `line_start`, `line_end` |
| Normal, Insert, Visual, Tree | `left`, `right` | | Normal, Insert, Visual, Tree | `left`, `right` |
| Normal, Insert, Visual, Selection, Tree | `down`, `up` | | Normal, Insert, Visual, Command, Search, Selection, Tree | `down`, `up` |
| Normal, Insert, Visual | `word_next`, `word_previous`, `word_end`, `big_word_next`, `big_word_previous`, `big_word_end` | | Normal, Insert, Visual | `word_next`, `word_previous`, `word_end`, `big_word_next`, `big_word_previous`, `big_word_end` |
| Normal, Insert | `delete` | | Normal, Insert | `delete` |
| Visual | `copy`, `cut`, `change`, `select_lines`, `select_next`, `skip_occurrence`, `select_all` | | Visual | `copy`, `cut`, `change`, `select_lines`, `select_next`, `skip_occurrence`, `select_all` |
@@ -315,20 +352,20 @@ config.bind "ctrl+h", nil, mode: config.modes.tree
The diff's changed-file explorer and floating picker use these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's comparison; writable After reuses its live buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan. The diff's changed-file explorer and floating picker use these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's comparison; writable After reuses its live buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan.
Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Escape closes the revision selector in either stage, but an opened comparison still closes explicitly through `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and exposes immutable `close`, `mode.toggle`, `view.toggle`, and `orientation.toggle` children. New comparisons default to Branch changes (merge base → selected After); Normal-mode Ctrl+b, `:diff.mode.toggle`, or the palette toggles to direct Snapshots and back, preserving the selected endpoints. Selecting Working checkout on either side forces Snapshots. Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Escape closes the revision selector in either stage, but an opened comparison still closes explicitly through `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and exposes immutable `close`, `mode.toggle`, `view.toggle`, and `orientation.toggle` children. New comparisons default to Branch changes (merge base → selected After); Normal-mode Ctrl+b, `:diff.mode.toggle`, or the palette toggles to direct Snapshots and back, preserving the selected endpoints. Selecting Checkout snapshot or Follow checkout forces Snapshots.
`config.core.diff.review.toggle` is a non-mutating Normal-mode action, also available as `:diff.review.toggle` and in the palette. It marks/unmarks the highlighted changed-file leaf when the comparison's explorer is focused, or the current comparison file from a text pane. Read-only snapshots permit review marks. The default `c` shortcut applies only while browsing the diff's changed-file tree; explicit bindings and disabled keys override it. For example, `config.bind "ctrl+g", config.core.diff.review.toggle`; use the palette or a control-key binding while filtering, including List view, so printable input stays in the query. `config.core.diff.review.toggle` is a non-mutating Normal-mode action, also available as `:diff.review.toggle` and in the palette. It marks/unmarks the highlighted changed-file leaf when the comparison's explorer is focused, or the current comparison file from a text pane. Read-only snapshots permit review marks. The default `c` shortcut applies only while browsing the diff's changed-file tree; explicit bindings and disabled keys override it. For example, `config.bind "ctrl+g", config.core.diff.review.toggle`; use the palette or a control-key binding while filtering, including List view, so printable input stays in the query.
Local comments use `config.core.diff.comment` in Normal or Visual mode; the remaining `config.core.diff.review` actions in the table above use Normal mode. They have no default bindings and are available through the palette and canonical Ex commands. `edit`, `delete`, `resolve`, `unresolve`, `verdict`, `save`, and `open` open argument prompts when selected as actions. Comment IDs are optional and default to the overview entry under the caret or selected comment. For example: Local comments use `config.core.comment` in Normal or Visual mode in ordinary repository source buffers and diff text panes; the remaining `config.core.review` actions in the table above use Normal mode. File checkmarks use `config.core.diff.review.toggle`. These actions have no default bindings and are available through the palette and Ex commands. `edit`, `delete`, `resolve`, `unresolve`, `verdict`, `save`, and `open` open argument prompts when selected as actions. Comment IDs are optional and default to the overview entry under the caret or selected comment. For example:
```ruby ```ruby
config.bind "leader r c", config.core.diff.comment config.bind "leader r c", config.core.comment
config.bind "leader r c", config.core.diff.comment, mode: config.modes.visual config.bind "leader r c", config.core.comment, mode: config.modes.visual
config.bind "leader r o", config.core.diff.review.overview config.bind "leader r o", config.core.review.overview
config.bind "leader r n", config.core.diff.review.next config.bind "leader r n", config.core.review.next
``` ```
Review editing uses a floating window without adding workspace splits. Dismissing or switching editors commits text to local SQLite; verdicts, deletions, and resolution changes commit immediately. `:w` commits explicitly; `:diff.review.save path` exports JSON without redirecting autosaves. `config.review_directory` defaults to `nil`, selecting `$XDG_DATA_HOME/vamp/reviews/` (absolute XDG path only), otherwise `~/.local/share/vamp/reviews/`. String overrides are resolved relative to the configuration file; empty/NUL-containing strings and non-string/non-nil values are rejected. These actions do not contact a forge. See [local review behavior](editing.md#repository-diff). Review editing uses a floating window without adding workspace splits. `:w` or `:review.save` commits comment/summary text, including hidden drafts; dismissing an editor discards its unsaved text, and switching editors does not save it. Verdicts, deletions, and resolution changes commit immediately without saving drafts. `:review.save path` additionally exports JSON without redirecting autosaves. `config.review_directory` defaults to `nil`, selecting `$XDG_DATA_HOME/vamp/reviews/` (absolute XDG path only), otherwise `~/.local/share/vamp/reviews/`. String overrides are resolved relative to the configuration file; empty/NUL-containing strings and non-string/non-nil values are rejected. These actions do not contact a forge. See [local review behavior](editing.md#repository-diff).
`config.core.pane.close` and `config.core.pane.force_close` close a focused **docked** file list instead of its editor pane, without discarding buffers or exiting the comparison. A floating picker does not intercept these actions: normal close still protects underlying dirty buffers, while force-close retains its existing discard semantics. For example, bind `config.bind "ctrl+x", config.core.pane.close`; focused tree/selection overrides and printable filter input retain priority. Palette-originated close actions retain the originating dock target through force confirmation. `config.core.pane.close` and `config.core.pane.force_close` close a focused **docked** file list instead of its editor pane, without discarding buffers or exiting the comparison. A floating picker does not intercept these actions: normal close still protects underlying dirty buffers, while force-close retains its existing discard semantics. For example, bind `config.bind "ctrl+x", config.core.pane.close`; focused tree/selection overrides and printable filter input retain priority. Palette-originated close actions retain the originating dock target through force confirmation.
@@ -341,7 +378,7 @@ config.bind "leader d q", config.core.diff.close
Ctrl+h is **not** a Backspace alias or default deletion key in any mode. Raw Ctrl+h (0x08) and Backspace (DEL/0x7f) are distinct, as are CSI-u events. In the tree it navigates in NORMAL and INSERT without deleting query text; it never deletes file-buffer Insert text by default. Explicit `config.core.backspace` bindings remain available if desired. Ctrl+h is **not** a Backspace alias or default deletion key in any mode. Raw Ctrl+h (0x08) and Backspace (DEL/0x7f) are distinct, as are CSI-u events. In the tree it navigates in NORMAL and INSERT without deleting query text; it never deletes file-buffer Insert text by default. Explicit `config.core.backspace` bindings remain available if desired.
Ctrl+j is distinct from Enter: in the TUI's raw mode, LF is Ctrl+j and CR is Enter; CSI-u control events also work. No enhanced-terminal negotiation is required. Ctrl+m remains an Enter alias. Outside completion and selection lists, Ctrl+k still clones above in Normal/Visual, and Ctrl+j defaults to newline in Insert or submit in Command/Search. While completion is open, Ctrl+j/k cycles candidates; Enter always closes completion and inserts a newline. Enter and Ctrl+j can be rebound independently outside the completion cycle. Ctrl+j is distinct from Enter: in the TUI's raw mode, LF is Ctrl+j and CR is Enter; CSI-u control events also work. No enhanced-terminal negotiation is required. Ctrl+m remains an Enter alias. Outside completion and selection lists, Ctrl+k still clones above in Normal/Visual, and Ctrl+j inserts a newline in Insert. In Command/Search, Up/Ctrl+k (`config.core.up`) recalls older history and Down/Ctrl+j (`config.core.down`) recalls newer history or restores unfinished input; Enter alone submits by default. These prompt bindings can be overridden independently with `mode: config.modes.command` or `mode: config.modes.search`; they do not use `config.modes.selection`. While completion is open, Ctrl+j/k cycles candidates; Enter always closes completion and inserts a newline. Enter and Ctrl+j can be rebound independently outside the completion cycle.
## Key sequences and leader ## Key sequences and leader
@@ -373,6 +410,7 @@ Vamp.configure do |config|
config.lsp["rust"] = true # rust-analyzer, extensions: ["rs"] config.lsp["rust"] = true # rust-analyzer, extensions: ["rs"]
config.lsp["ruby"] = true # solargraph stdio, extensions: ["rb"] config.lsp["ruby"] = true # solargraph stdio, extensions: ["rb"]
config.lsp["python"] = true # ty server + ruff server, extensions: ["py"] config.lsp["python"] = true # ty server + ruff server, extensions: ["py"]
config.lsp["typst"] = true # tinymist lsp, extensions: ["typ"]
config.lsp["my-language"] = { config.lsp["my-language"] = {
command: ["my-language-server", "--stdio"], command: ["my-language-server", "--stdio"],
@@ -387,12 +425,37 @@ Vamp.configure do |config|
end end
``` ```
Keys are arbitrary nonempty strings, used as the protocol's `languageId`; they need no Tree-sitter grammar or hardcoded language enum. Absent/`false` entries are disabled. `true` enables a built-in preset: `"rust"` uses `rust-analyzer`/`rs`, `"python"` uses `ty server` plus `ruff server`/`py`, and `"ruby"` uses `solargraph stdio`/`rb`. Unknown keys need a configuration object with nonempty `command` argv or a `servers` list, plus `extensions` (without leading dots). Objects also explicitly enable servers; known presets supply omitted command/extensions. An explicit `command` replaces the entire preset with one server: `config.lsp["rust"] = { command: ["/custom/path/rust-analyzer"] }` retains `rs`, while a Python command override does not also launch Ruff. Keys are arbitrary nonempty strings, used as the protocol's `languageId`; they need no Tree-sitter grammar or hardcoded language enum. Absent/`false` entries are disabled. `true` enables a built-in preset: `"rust"` uses `rust-analyzer`/`rs`, `"python"` uses `ty server` plus `ruff server`/`py`, `"ruby"` uses `solargraph stdio`/`rb`, and `"typst"` uses `tinymist lsp`/`typ`. Unknown keys need a configuration object with nonempty `command` argv or a `servers` list, plus `extensions` (without leading dots). Objects also explicitly enable servers; known presets supply omitted command/extensions. An explicit `command` replaces the entire preset with one server: `config.lsp["rust"] = { command: ["/custom/path/rust-analyzer"] }` retains `rs`, while a Python command override does not also launch Ruff.
Only `command`, `extensions`, `initialization_options`, `settings`, and `servers` are accepted. Options/settings must be JSON-compatible objects. Without an explicit command/list, options apply to every default server; explicit `initialization_options` replaces preset defaults. `servers` must be a nonempty ordered list of objects with explicit `command` and optional per-server `initialization_options`/`settings`; it shares language-level extensions and cannot be combined with top-level command/options/settings. Invalid declarations reject configuration with a notice rather than activating a guessed server. Commands are executable/argument arrays, never shell commands; use an absolute path if the executable is not on PATH. No server installation or restart is automatic. Only `command`, `extensions`, `initialization_options`, `settings`, and `servers` are accepted. Options/settings must be JSON-compatible objects. Without an explicit command/list, options apply to every default server; explicit `initialization_options` replaces preset defaults. `servers` must be a nonempty ordered list of objects with explicit `command` and optional per-server `initialization_options`/`settings`; it shares language-level extensions and cannot be combined with top-level command/options/settings. Invalid declarations reject configuration with a notice rather than activating a guessed server. Commands are executable/argument arrays, never shell commands; use an absolute path if the executable is not on PATH. No server installation or restart is automatic.
Install Solargraph yourself with `gem install solargraph` in the project's Ruby environment. The Ruby preset launches `["solargraph", "stdio"]`, following [Solargraph's stdio integration](https://solargraph.org/guides/language-server), and uses [YARD annotations](https://solargraph.org/guides/yard) for type information. Explicit `command` overrides replace the entire argv: for a bundled installation, use `config.lsp["ruby"] = { command: ["bundle", "exec", "solargraph", "stdio"] }`; Vamp retains the `rb` extension but does not append `stdio` to your override. Install Solargraph yourself with `gem install solargraph` in the project's Ruby environment. The Ruby preset launches `["solargraph", "stdio"]`, following [Solargraph's stdio integration](https://solargraph.org/guides/language-server), and uses [YARD annotations](https://solargraph.org/guides/yard) for type information. Explicit `command` overrides replace the entire argv: for a bundled installation, use `config.lsp["ruby"] = { command: ["bundle", "exec", "solargraph", "stdio"] }`; Vamp retains the `rb` extension but does not append `stdio` to your override.
Install [Tinymist](https://github.com/Myriad-Dreamin/tinymist#installation) separately and enable `config.lsp["typst"] = true` for `.typ` files. The preset launches `["tinymist", "lsp"]` with language ID `typst`; it does not enable preview or add export settings. Tinymist builds that return overlapping semantic tokens despite Vamp advertising no overlap support retain Tree-sitter highlighting and report the rejected tokens; hover and other LSP requests remain available. This occurs with the installed Typst 0.14.2-based build (`tinymist/v0.14.6-rc2`).
**Config-file lint style:** Solargraph's RuboCop reporter discovers `.rubocop.yml` from the Ruby file's directory, not Vamp's workspace. A config under `~/.config/vamp/` therefore does not inherit this repository's policy. To keep double quotes, multiline trailing argument commas, commented-out settings without a space after `#`, no required frozen-string header, and no block-length limit on `Vamp.configure`, place this `.rubocop.yml` beside `config.rb` (also for a custom `--config` path):
```yaml
Style/StringLiterals:
EnforcedStyle: double_quotes
Style/TrailingCommaInArguments:
EnforcedStyleForMultiline: comma
Style/FrozenStringLiteralComment:
Enabled: false
Layout/LeadingCommentSpace:
Enabled: false
Metrics/BlockLength:
AllowedMethods:
- refine
- Vamp.configure
```
Syntax errors and other lint checks remain enabled. The block-length exemption applies only to `Vamp.configure`, alongside RuboCop's default `refine` exemption; ordinary blocks still have the normal limit. Restart Vamp after changing the policy so Solargraph reloads it; the Ruby config itself need not change.
**Python (Astral):** install [ty](https://docs.astral.sh/ty/editors/) and [Ruff](https://docs.astral.sh/ruff/editors/setup/) yourself, for example with `uv tool install ty` and `uv tool install ruff`. The Python preset sends the same documents to both servers, uses ty for hover/definition, and merges type errors with Ruff lint diagnostics. Clearing or losing one server preserves the other's diagnostics. Formatting and code actions remain unsupported by Vamp. **Python (Astral):** install [ty](https://docs.astral.sh/ty/editors/) and [Ruff](https://docs.astral.sh/ruff/editors/setup/) yourself, for example with `uv tool install ty` and `uv tool install ruff`. The Python preset sends the same documents to both servers, uses ty for hover/definition, and merges type errors with Ruff lint diagnostics. Clearing or losing one server preserves the other's diagnostics. Formatting and code actions remain unsupported by Vamp.
Use `servers` to customize the stack independently; order selects the hover/definition/completion server: Use `servers` to customize the stack independently; order selects the hover/definition/completion server:
+56 -6
View File
@@ -23,6 +23,8 @@ The default workspace member is the editor. `vendor/crossterm` is included to te
`vendor/tree-sitter-honey` contains a minimal Rust binding plus the unchanged generated parser, parser header, and highlight query from [`valdemar/tree-sitter-honey`](https://github.com/valdemar/tree-sitter-honey), revision `10f1ff746fe57736ae7d7c8c402f7b265680b26c` (MIT). Builds do not depend on a sibling checkout or an installed Tree-sitter CLI. To update, copy `src/parser.c`, `src/tree_sitter/parser.h`, and `queries/highlights.scm` from the same grammar revision, update the revision recorded here, and run `cargo test --lib syntax::tests`. `vendor/tree-sitter-honey` contains a minimal Rust binding plus the unchanged generated parser, parser header, and highlight query from [`valdemar/tree-sitter-honey`](https://github.com/valdemar/tree-sitter-honey), revision `10f1ff746fe57736ae7d7c8c402f7b265680b26c` (MIT). Builds do not depend on a sibling checkout or an installed Tree-sitter CLI. To update, copy `src/parser.c`, `src/tree_sitter/parser.h`, and `queries/highlights.scm` from the same grammar revision, update the revision recorded here, and run `cargo test --lib syntax::tests`.
Typst uses the published [`codebook-tree-sitter-typst` 0.12.1 grammar](https://docs.rs/codebook-tree-sitter-typst/0.12.1/codebook_tree_sitter_typst/), aliased as `tree-sitter-typst` and pinned because the crate does not export queries. `src/syntax/languages.rs` supplies supported highlight captures and standard raw-code injection captures against that grammar's AST. After changing the grammar or query, run `cargo test --lib syntax::`; coverage includes Typst markup/code, incremental edits, Markdown fences, and embedded Rust. Live network-denied PTY smoke verifies heading/keyword/string/function colors, the opt-in Tinymist status, an unknown-variable diagnostic, and a `text()` hover signature with unchanged source bytes.
## Ruby types ## Ruby types
Use YARD `@param`, `@return`, and `@yieldparam` annotations for Ruby APIs, and `@type` for locals where inference needs help. Examples annotate DSL block parameters before the call, without adding runtime assignments or dependencies. `Vamp::Action` / `Vamp::Mode` field types and the dynamically defined mode methods use Solargraph-supported directives. Use YARD `@param`, `@return`, and `@yieldparam` annotations for Ruby APIs, and `@type` for locals where inference needs help. Examples annotate DSL block parameters before the call, without adding runtime assignments or dependencies. `Vamp::Action` / `Vamp::Mode` field types and the dynamically defined mode methods use Solargraph-supported directives.
@@ -41,9 +43,33 @@ Rust tests live under [`tests/`](../tests/). Private unit tests are in [`tests/u
`cargo test` runs both groups; `cargo test --lib` and `cargo test --bin vamp` select the library and CLI unit suites. Do not name a unit file `tests/unit/main.rs`: Cargo would discover it as a separate integration target. `cargo test` runs both groups; `cargo test --lib` and `cargo test --bin vamp` select the library and CLI unit suites. Do not name a unit file `tests/unit/main.rs`: Cargo would discover it as a separate integration target.
### External-service isolation
Tests must not contact real websites or APIs. Forge tests inject JSON responses through `page_with`, `metadata_with`, and `resolve_with`; listing workers inject fetch closures. Repository remotes are temporary local repositories (including missing local paths for failure cases). LSP servers, Ruby plugins, and shell commands are local fixture subprocesses. The SSH alias check uses `ssh -G` with a temporary config to inspect configuration without connecting.
On macOS, verify external-service isolation with networking denied for Cargo and all descendants:
```sh
sandbox-exec -p '(version 1) (allow default) (deny network*)' \
cargo test --offline --no-fail-fast -- --test-threads=1 \
--skip shell::tests::completed_shell_does_not_leave_background_jobs_holding_output_open \
--skip shell::tests::dropping_shell_kills_and_reaps_group_with_pipe_inheriting_descendant
cargo test --offline --lib shell::tests:: -- --test-threads=1
```
Dependencies must already be cached. `--offline` prevents Cargo downloads; it does not block networking by tests or subprocesses. The sandbox supplies that boundary, including for real Git/jj operations and fixture programs; do not replace those local integration checks with mocks.
The two skipped shell cleanup checks execute `/bin/ps`, which macOS refuses to launch even under an allow-all sandbox profile; run them outside the sandbox as shown. Serial execution avoids contention in subprocess fixtures with production deadlines.
### Coverage
- Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity. - Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity.
- Visual-command checks preserve directed characterwise/linewise ranges and multiple carets through command cancellation and plugin dispatch, exercise remapped command entry, and submit `:diff.comment` on either historical side. Live PTY smoke selects lines, cancels a prompt, then runs `:diff.comment` directly and verifies the saved original range without using the palette. - Prompt-history checks cover separate session-wide command/search entries, Unicode draft restoration, editable recalls, clamped/empty/bounded history, duplicate/empty/cancelled submissions, remapped/disabled keys, and preserved Visual/read-only diff targets. Live PTY smoke exercises arrows and raw Ctrl+k/j, restores drafts, accepts a recalled search with Enter, and checks command/search isolation and unchanged source bytes.
- Presentation regressions render retained characterwise/linewise highlights during command entry in ordinary, split-diff, and unified views. Floating-review checks preserve source rectangles and the workspace layout, place the caret inside the overlay, retain dismissed drafts across tab switches, and restore source focus on Escape or pane close. Live PTY smoke edits, dismisses, reopens, and saves a floating comment and summary before returning to the unchanged diff. - Command-completion checks cover forward/backward alphabetical cycling/wrap across core names, aliases and live plugin registrations, reverse-first completion and direction changes retaining the prefix, explicit execution, edits/history/cancel resets, empty/unknown/Unicode prefixes, untouched arguments, remapped/disabled keys, and preserved Visual/read-only diff targets and literal search tabs. Live PTY smoke cycles `tab.*` in both directions through wraparound, submits a completed `tab.new`, and exits with unchanged source bytes.
- Shell-command checks use file-gated subprocesses to prove editing remains live, stdout/stderr/status and cwd handling, null stdin, output bounds, launch/worker errors, and process-group cleanup on completion/drop. Spinner timing uses an injected `Instant`; render checks cover scrolling, dismissal, hidden source caret, and tiny/offset windows. Live PTY smoke gates completion, saves an edit while pending, then observes both output streams and exit status and dismisses the popup.
- Visual-command checks preserve directed characterwise/linewise ranges and multiple carets through command cancellation and plugin dispatch, exercise remapped command entry, and submit `:comment` on either historical side. Live PTY smoke selects lines, cancels a prompt, then runs `:comment` directly and verifies the saved original range without using the palette.
- Presentation regressions render retained characterwise/linewise highlights during command entry in ordinary, split-diff, and unified views. Floating-review checks preserve source rectangles and the workspace layout, place the caret inside the overlay, retain explicitly saved text across tab switches, and restore source focus on Escape or pane close.
- Review prose checks render long comments and summaries with wrapping enabled by default in split/unified views, preserve exact single-line text and source wrapping, and retain an explicit wrap toggle on reopening. Live PTY smoke displays a long comment across continuation rows, saves it, verifies exact text through the review CLI, and reopens it still wrapped.
- Precise-range checks cover forward/reversed characterwise and partial-multiline selections on both sides, linewise and caret/empty-line fallbacks, source-column navigation, Unicode grapheme bounds, invalid schemas, and preserved save-conflict checks. Render checks cover exact text/newline cells, wrapping, horizontal clipping, shared unified context, stale suppression, and native Visual precedence. Live PTY smoke saves and reopens a selection containing a wide and combining grapheme. - Precise-range checks cover forward/reversed characterwise and partial-multiline selections on both sides, linewise and caret/empty-line fallbacks, source-column navigation, Unicode grapheme bounds, invalid schemas, and preserved save-conflict checks. Render checks cover exact text/newline cells, wrapping, horizontal clipping, shared unified context, stale suppression, and native Visual precedence. Live PTY smoke saves and reopens a selection containing a wide and combining grapheme.
- Markdown navigation checks cover inline/reference destinations, escaped/percent-encoded paths, heading markup and duplicates, same-document fragments, dirty target reuse, failed navigation atomicity, primary-only following, and `gf` prefix cancellation/configuration overrides. - Markdown navigation checks cover inline/reference destinations, escaped/percent-encoded paths, heading markup and duplicates, same-document fragments, dirty target reuse, failed navigation atomicity, primary-only following, and `gf` prefix cancellation/configuration overrides.
- Jump-history checks cover cross-buffer/same-document navigation, search and picker recording, counts and repeated visits, bounded history, branch truncation, closed buffers, edited-coordinate clamping, dirty text/undo preservation, independent Ctrl+i/Tab bindings, and custom bindings. Diff checks exercise Ctrl+i on either side in split/unified views, both control-letter and modified-Tab terminal events, and plain Tab pane cycling. Real LSP fixtures cover definition round-trips and delayed replies after leaving and returning to the same source location. - Jump-history checks cover cross-buffer/same-document navigation, search and picker recording, counts and repeated visits, bounded history, branch truncation, closed buffers, edited-coordinate clamping, dirty text/undo preservation, independent Ctrl+i/Tab bindings, and custom bindings. Diff checks exercise Ctrl+i on either side in split/unified views, both control-letter and modified-Tab terminal events, and plain Tab pane cycling. Real LSP fixtures cover definition round-trips and delayed replies after leaving and returning to the same source location.
@@ -53,7 +79,8 @@ Rust tests live under [`tests/`](../tests/). Private unit tests are in [`tests/u
- Battery checks inject samples and refresh gates, use temporary Linux sysfs fixtures, and validate native value types/bounds without depending on the machine's charge or clock. Unit sessions do not automatically read hardware. Live smoke should compare the default indicator with OS telemetry and observe a production refresh; no-battery, explicit overrides, and disabled anchors must not fabricate values. - Battery checks inject samples and refresh gates, use temporary Linux sysfs fixtures, and validate native value types/bounds without depending on the machine's charge or clock. Unit sessions do not automatically read hardware. Live smoke should compare the default indicator with OS telemetry and observe a production refresh; no-battery, explicit overrides, and disabled anchors must not fabricate values.
- Status redesign checks cover component-aware home abbreviation without save-path changes; floating command/search submit/cancel, Unicode/tiny/offset clipping and wheel isolation; footer-row reclamation; notification Escape/modal/prefix priority and covered-caret visibility; and ready-server metadata using real framed subprocess fixtures, including stacks, initialization gates, failure, and focus changes. - Status redesign checks cover component-aware home abbreviation without save-path changes; floating command/search submit/cancel, Unicode/tiny/offset clipping and wheel isolation; footer-row reclamation; notification Escape/modal/prefix priority and covered-caret visibility; and ready-server metadata using real framed subprocess fixtures, including stacks, initialization gates, failure, and focus changes.
- Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover raw/alternate-screen/mouse-capture cleanup on entry failure, normal return, I/O errors, and panics during frame construction, including original panic-payload propagation. - Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover raw/alternate-screen/mouse-capture cleanup on entry failure, normal return, I/O errors, and panics during frame construction, including original panic-payload propagation.
- Redraw checks inspect actual Crossterm output during repeated scrolling: supporting terminals receive synchronized frames, terminals ignoring synchronization never paint with a visible hardware cursor, and the final caret remains visible. A protocol-state regression verifies that the terminal is not left in synchronized-output mode while the next frame is computed. - Redraw checks inspect actual Crossterm output during repeated scrolling: supporting terminals receive synchronized frames, terminals ignoring synchronization never paint with a visible hardware cursor, and the final software caret remains visible. A protocol-state regression verifies that the terminal is not left in synchronized-output mode while the next frame is computed.
- Cursor checks render software block/bar/underline shapes, wide/combining graphemes, cell restoration after motion, neutral reverse-video blocks, and shared ANSI mode accents on both banners through Normal, Insert, Visual, Command, and Search. The production backend preserves logical cursor coordinates without showing the hardware cursor. Live PTY screen parsing and an isolated Herdr 0.9.5 full-client smoke verify that mode-colored cells survive multiplexing, match both banners, avoid OSC 12, leave source bytes unchanged, and restore hardware visibility on exit. Document-layer fixtures exclude the final cursor overlay; complete-frame checks use `BufferViewports` with the production backend adapter.
- Wheel checks render document/panel views and cover three-row scrolling, unchanged carets/selections/text, first/last-page clamping, offscreen cursor hiding, no-op/custom-bound navigation reveal, manual persistence through idle/resize/palette updates, and floating/unfocused-docked explorer selection and focus preservation. Queued-input checks verify that the next frame shows ordered wheel steps without crossing a key or resize boundary. - Wheel checks render document/panel views and cover three-row scrolling, unchanged carets/selections/text, first/last-page clamping, offscreen cursor hiding, no-op/custom-bound navigation reveal, manual persistence through idle/resize/palette updates, and floating/unfocused-docked explorer selection and focus preservation. Queued-input checks verify that the next frame shows ordered wheel steps without crossing a key or resize boundary.
- Queued-navigation checks cover counted ordered motions through short rows, Normal/Visual ranges, remapped press/repeat keys, rendered caret visibility, edit/layout/prefix and non-key barriers, and the 64-key fairness bound. Burst smoke must verify the final cursor position as well as frame count; batching reduces queued redraws without increasing the OS/terminal repeat rate. - Queued-navigation checks cover counted ordered motions through short rows, Normal/Visual ranges, remapped press/repeat keys, rendered caret visibility, edit/layout/prefix and non-key barriers, and the 64-key fairness bound. Burst smoke must verify the final cursor position as well as frame count; batching reduces queued redraws without increasing the OS/terminal repeat rate.
- Native LSP integration checks launch framed-protocol fixture subprocesses, covering initialization/settings, opt-in startup, synchronization, UTF-16/file-URI conversion, dirty targets, stale requests/diagnostics, and transport failures. Stack checks cover independent initialization, diagnostic merging/clearing, sibling failure isolation in active/parked buffers, same-revision rename/close identity, aggregate limits, first-server position routing, and both shutdowns. Semantic ordering uses protocol/file gates; elapsed deadlines only bound stuck subprocesses. - Native LSP integration checks launch framed-protocol fixture subprocesses, covering initialization/settings, opt-in startup, synchronization, UTF-16/file-URI conversion, dirty targets, stale requests/diagnostics, and transport failures. Stack checks cover independent initialization, diagnostic merging/clearing, sibling failure isolation in active/parked buffers, same-revision rename/close identity, aggregate limits, first-server position routing, and both shutdowns. Semantic ordering uses protocol/file gates; elapsed deadlines only bound stuck subprocesses.
@@ -64,30 +91,53 @@ Clipboard checks use isolated providers rather than reading or overwriting the d
Diff checks cover staged category/filter navigation, stale request cancellation, row alignment, read-only buffer isolation, and rendered panes. Repository integration fixtures exercise Git and Git-backed jj, advertised but unfetched remote branches/bookmarks/tags, imported historical commit/change IDs, conflicts, unusual paths, binary/mode/link/submodule changes, and unchanged active metadata. Remote fixtures are local bare copies; they never push. Git fixture identities and commit dates are fixed. Diff checks cover staged category/filter navigation, stale request cancellation, row alignment, read-only buffer isolation, and rendered panes. Repository integration fixtures exercise Git and Git-backed jj, advertised but unfetched remote branches/bookmarks/tags, imported historical commit/change IDs, conflicts, unusual paths, binary/mode/link/submodule changes, and unchanged active metadata. Remote fixtures are local bare copies; they never push. Git fixture identities and commit dates are fixed.
Pull-request checks cover ranked sort menus, rank collisions/default slots, timestamp directions, independent personal review flags, checkbox unions, Apply/Cancel, category/query composition, streamed updates, and selection identity. Storage and session checks cover restart persistence, config section overrides, corrupt/incompatible-file preservation, and failed writes with isolated paths. Live PTY smoke at 100×24 and 44×16 exercises checkbox Apply/Cancel, restart restoration, creation-rank movement and ascending direction, and a config filter override. All forge responses come from a disposable curl fixture; the editor and descendants run with networking denied.
Session-filter regressions verify no file creation on filter Apply, retention across picker reopenings, restart reset, and saved sorting without filter data. Previously saved filters, including invalid legacy values, are ignored without altering valid sorting or changing the file on load. Live network-denied PTY smoke checks these transitions plus configured filter restoration and the active-filter indicator.
Diff-stat checks cover unopened frozen files, additions/deletions/renames, binary/mode changes, CRLF/EOF, refresh, oversized-file laziness, filtering, live edits/undo, and inactive tabs. Live PTY smoke opens a two-file Git Working comparison, observes selected-file `+2 -1` alongside whole-diff `+2 -2` before opening the second file, edits and undoes text, switches to an ordinary tab, and exits with source bytes and HEAD unchanged.
Local review checks cover native comment/summary composition and undo, Visual ranges on either side, verdicts, JSON save/reopen, external-write refusal, hidden drafts and teardown guards, immutable Working identities in Git/jj, renamed paths, live EOF, and stale anchors. Render checks cover selected/range markers, split/unified context, wrapped continuations, and narrow gutters. Live PTY smoke creates and saves a comment/summary/verdict, closes and reopens the review, edits the reopened comment, changes the reviewed source, and observes stale status without altering the saved anchor. Fixtures are local repositories; no forge writes. Local review checks cover native comment/summary composition and undo, Visual ranges on either side, verdicts, JSON save/reopen, external-write refusal, hidden drafts and teardown guards, immutable Working identities in Git/jj, renamed paths, live EOF, and stale anchors. Render checks cover selected/range markers, split/unified context, wrapped continuations, and narrow gutters. Live PTY smoke creates and saves a comment/summary/verdict, closes and reopens the review, edits the reopened comment, changes the reviewed source, and observes stale status without altering the saved anchor. Fixtures are local repositories; no forge writes.
SQLite review checks cover exact anchors across restarts and revision changes, repository isolation, independent connections and optimistic conflicts, locked/unwritable stores, incompatible schema preservation, bounded/corrupt documents, private file permissions, and XDG/config path resolution. Editor checks commit text on dismissal and switching, 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 restarts three editor processes against one temporary XDG database and verifies stable IDs, Unicode anchors, automatic summary saves, JSON export isolation, and selector/verdict persistence. 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 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, 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 autosaves 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 resolution checks exercise positive review/comment IDs, preserved bodies/immutable anchors and other review metadata, unresolved filtering/full counts, `show --all`, repository isolation, missing/invalid targets and absent storage, idempotent repeats without revision changes, and stale-save rejection through the executable. CLI smoke: show a review as JSON, resolve one comment with `--repo PATH --json`, confirm counts and default filtering, show it again with `--all` to compare the original body/anchor/excerpt, and repeat resolution to confirm an unchanged storage 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.
Reviewed-file checks cover SQLite/JSON round-trips, unmarking, exact revision pairs and rename paths across catalog reordering, lazy loading, live text/final-newline mismatch, edit-triggered invalidation, selected-review isolation, hidden-draft isolation, conflicts, and transactional rollback. Live PTY smoke marks a file in a real Git comparison, verifies the SQLite row, restarts the editor, observes the restored checkmark, unmarks it, and verifies row removal with unchanged source bytes.
Per-file persistence also exercises a real Git Working comparison: mark two lazy files, save/reload the review, edit one file, refresh the snapshot, and retain only the unchanged file's mark through detail loading.
Conflicted-revision regressions preserve unchanged symlinks, defer oversized unselected blobs until file selection, compare selected conflict text with jj's projection, and exclude unchanged conflicts when comparing later revisions. Live terminal smoke covers local bookmark selection and opening an actual conflicted file without changing the checkout. Conflicted-revision regressions preserve unchanged symlinks, defer oversized unselected blobs until file selection, compare selected conflict text with jj's projection, and exclude unchanged conflicts when comparing later revisions. Live terminal smoke covers local bookmark selection and opening an actual conflicted file without changing the checkout.
Resolver checks use isolated real jj repositories: read-only discovery, marker-free all-side/base projections preserving outside edits and EOF, initial seed undo, hunk choice/undo, retained resolved files, jj-authoritative status, checkout/external-write refusal, cancellation, nontext bytes/types/modes/deletion, and directory-child protection. Render checks cover aligned gaps/highlights, context folding, native Draft focus/search/selection, separate History, hidden/on-demand explorer, viewport retention and narrow layouts. Live PTY smoke exercises frozen marker-free alternatives, History/Draft switching, hunk choice and guarded save confirmation without touching user repositories. Resolver checks use isolated real jj repositories: read-only discovery, marker-free all-side/base projections preserving outside edits and EOF, initial seed undo, hunk choice/undo, retained resolved files, jj-authoritative status, checkout/external-write refusal, cancellation, nontext bytes/types/modes/deletion, and directory-child protection. Render checks cover aligned gaps/highlights, context folding, native Draft focus/search/selection, separate History, hidden/on-demand explorer, viewport retention and narrow layouts. Live PTY smoke exercises frozen marker-free alternatives, History/Draft switching, hunk choice and guarded save confirmation without touching user repositories.
Base-relative resolver regressions cover red display-only removals and green native additions on both sides, deletion/replacement gaps, add/add without a base, native-coordinate round trips and EOF rows. The rendered workspace check exercises both split orientations and unified mode, native edits, hunk choice/undo, and both History layouts. Live PTY smoke checks actual red `-` and green `+` cells on both candidates, opens columns/base-below History, verifies browsing leaves conflict bytes untouched, and takes/saves an Alternative with exact output bytes and jj confirmation.
Conflict History layout checks load `resolve_history_layout` through the real Ruby configuration and render both left/base/right columns and left/right over base. They cover source contents, full-width base geometry, Tab/directional focus, synchronized wheel scrolling, read-only base protection, narrow/short fallbacks and preserved Draft text. Live PTY smoke opens both layouts, focuses/scrolls the base, returns to Resolution, takes a hunk and saves; jj confirms the checkout is no longer conflicted. Conflict History layout checks load `resolve_history_layout` through the real Ruby configuration and render both left/base/right columns and left/right over base. They cover source contents, full-width base geometry, Tab/directional focus, synchronized wheel scrolling, read-only base protection, narrow/short fallbacks and preserved Draft text. Live PTY smoke opens both layouts, focuses/scrolls the base, returns to Resolution, takes a hunk and saves; jj confirms the checkout is no longer conflicted.
Shared-workspace parity checks compare ordinary and resolver diff geometry/content under identical chrome, focus and source cursors. Cover native picker selection/remaps, Tree/List and dock/floating actions, excerpt initialization, folded search, focus transfer on wheel, layout controls, loading/History/pane-rebuild transitions, and preserved per-file viewport state. Live PTY smoke uses Space e, Down/Enter file selection and guarded saving through the same explorer path. Shared-workspace checks exercise ordinary and resolver diffs with native chrome, focus and source cursors. Cover native picker selection/remaps, Tree/List and dock/floating actions, excerpt initialization, folded search, focus transfer on wheel, layout controls, loading/History/pane-rebuild transitions, and preserved per-file viewport state. Live PTY smoke uses Space e, Down/Enter file selection and guarded saving through the same explorer path.
Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart. Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart.
Blame checks cover selected historical commits rather than live HEAD, renamed/deleted paths, unsafe/nonregular sources, Git-backed non-colocated jj, and privately fetched objects surviving comparison/repository teardown until the final snapshot releases them. Render/session regressions cover both split/unified sides, stale side results, wrapping, narrow panes, detached historical tabs, and Working attribution. Live PTY smoke opens both historical sides and unified view after deleting the file from the checkout, observes the correct commit labels, opens a historical tab, and exits with unchanged HEAD and tracked state.
Partial-label checks navigate short and longer source lines with wide/combining author names and long commit summaries in ordinary, split, and unified views. Live PTY smoke repeats three-line navigation in split/unified views with a long summary, observing attribution on every source line with unchanged source bytes and HEAD.
Normal-picker checks cover staged/disk/unsaved status, binary and mode changes, exact and ambiguous renames, deleted paths, unborn repositories, aliases and unusual filenames, ignores and safe roots, linked worktrees, and read-only colocated/non-colocated/conflicted jj parents. Catalog/render checks retain unchanged rows, query/selection/expansion and Tree/List navigation, clear stale metadata, restore recreated files, and sanitize rename labels. Live PTY smoke checks A/M/D/R colors in floating and docked pickers with the line gutter disabled, Git HEAD/jj parent refresh, and unchanged jj operation state during polling. Normal-picker checks cover staged/disk/unsaved status, binary and mode changes, exact and ambiguous renames, deleted paths, unborn repositories, aliases and unusual filenames, ignores and safe roots, linked worktrees, and read-only colocated/non-colocated/conflicted jj parents. Catalog/render checks retain unchanged rows, query/selection/expansion and Tree/List navigation, clear stale metadata, restore recreated files, and sanitize rename labels. Live PTY smoke checks A/M/D/R colors in floating and docked pickers with the line gutter disabled, Git HEAD/jj parent refresh, and unchanged jj operation state during polling.
Diff LSP integration checks reuse the real framed-protocol subprocess with test-controlled response/publication gates and local Git comparisons. Cover both immutable historical sides and writable Working After, snapshot text/URI isolation, Unicode UTF-16 requests/targets/diagnostics, normal overlong-column clamping, hover, completion/undo, and read-only editing guards. Definition routing must distinguish ranges overlapping actual changed rows from wholly unchanged/context/omitted targets, use full location-link target ranges with exact selection reveal and exclusive endpoints, route canonical aliases and old/new rename paths, load other changed files on demand, handle added/deleted files, preserve original tabs/panes/excerpts and shared dirty targets, retain snapshot identity in ordinary tabs, and fail atomically for missing/non-UTF-8 files, invalid ranges, and unsupported URIs. Delayed/superseded replies must remain stale across caret moves, edit/undo, side/file/pane/tab changes, and comparison closure/replacement; publications for disposed snapshot identities must not reach their replacements. Private editor checks gate pending repository-load cancellation and exact-row reveal without timing assumptions. Before marking the TODO complete, run native integration/unit checks and an actual terminal smoke of focused diff LSP actions, changed-line routing, external new tabs, and immutable/editable side behavior. Diff LSP integration checks reuse the real framed-protocol subprocess with test-controlled response/publication gates and local Git comparisons. Cover both immutable historical sides and writable Working After, snapshot text/URI isolation, Unicode UTF-16 requests/targets/diagnostics, normal overlong-column clamping, hover, completion/undo, and read-only editing guards. Definition routing must distinguish ranges overlapping actual changed rows from wholly unchanged/context/omitted targets, use full location-link target ranges with exact selection reveal and exclusive endpoints, route canonical aliases and old/new rename paths, load other changed files on demand, handle added/deleted files, preserve original tabs/panes/excerpts and shared dirty targets, retain snapshot identity in ordinary tabs, and fail atomically for missing/non-UTF-8 files, invalid ranges, and unsupported URIs. Delayed/superseded replies must remain stale across caret moves, edit/undo, side/file/pane/tab changes, and comparison closure/replacement; publications for disposed snapshot identities must not reach their replacements. Private editor checks gate pending repository-load cancellation and exact-row reveal without timing assumptions. Before marking the TODO complete, run native integration/unit checks and an actual terminal smoke of focused diff LSP actions, changed-line routing, external new tabs, and immutable/editable side behavior.
Changed-file explorer checks cover reuse of the compact directory tree, removed/historical paths, file-to-directory transitions, remapped tree/selection bindings, filter/open/focus transitions, right-dock geometry, and wheel routing without edits to the underlying document. Changed-file explorer checks cover reuse of the compact directory tree, removed/historical paths, file-to-directory transitions, remapped tree/selection bindings, filter/open/focus transitions, right-dock geometry, and wheel routing without edits to the underlying document.
Visibility checks preserve browsing and pane state across Ctrl+e, exclude hidden explorers from focus/wheel routing, and verify full-width review. Status checks preserve catalog associations through sorting, compaction, duplicate inputs, and file/directory collisions, and cover colored A/M/D/R markers, selected/unfocused rows, Tree/List filtering, and sanitized rename paths. Visibility checks preserve browsing and pane state across Ctrl+e, exclude hidden explorers from focus/wheel routing, and verify full-width review. Status checks preserve catalog associations through sorting, compaction, duplicate inputs, and file/directory collisions, and cover colored A/M/D/R markers, selected/unfocused rows, Tree/List filtering, and sanitized rename paths.
Light-terminal explorer selection checks render focused/unfocused rows through theme changes, require readable filename/status/directory contrast on Pumpkin Light, and retain dark/unknown-terminal fallbacks. A live PTY check answers native OSC color queries, opens the explorer with Space e, and observes the pale truecolor selection without changing source bytes.
Diff styling checks cover independent snapshot language/shebang/injection detection, accepted/stale snapshot identity, real-parser cache freshness/reuse/release, syntax foreground over changed backgrounds, real empty rows versus missing counterparts, scrolling-stable diagonal hatching, and tab/wide/combining/control-character clipping. TestBackend omits wide-glyph continuation cells; terminal smoke verifies their actual styling. Diff styling checks cover independent snapshot language/shebang/injection detection, accepted/stale snapshot identity, real-parser cache freshness/reuse/release, syntax foreground over changed backgrounds, real empty rows versus missing counterparts, scrolling-stable diagonal hatching, and tab/wide/combining/control-character clipping. TestBackend omits wide-glyph continuation cells; terminal smoke verifies their actual styling.
## Performance and process limits ## Performance and process limits
+100 -40
View File
@@ -51,8 +51,8 @@ Indicators refresh asynchronously. Typing and undo/redo retain the last complete
| Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End at every caret outside snippets; Esc returns to Normal, retaining carets | | Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End at every caret outside snippets; Esc returns to Normal, retaining carets |
| Insert | Ctrl+n requests completion at the primary caret or cycles next; Ctrl+j/k cycles next/previous while completion is open | | Insert | Ctrl+n requests completion at the primary caret or cycles next; Ctrl+j/k cycles next/previous while completion is open |
| Insert, active snippet | Type to replace the highlighted argument and its mirrors; Tab / Shift+Tab moves next/previous | | Insert, active snippet | Type to replace the highlighted argument and its mirrors; Tab / Shift+Tab moves next/previous |
| Command | Enter executes; Backspace edits; Esc cancels | | Command | Tab / Shift+Tab completes/cycles command names forward/backward; Enter executes; ↑/↓ or Ctrl+k/j browse history; Backspace edits; Esc cancels |
| Search | Type a literal query; Enter accepts the current occurrence or finds the next match; Backspace edits; Esc cancels | | Search | Type a literal query; Enter accepts the current occurrence or finds the next match; ↑/↓ or Ctrl+k/j browse history; Backspace edits; Esc cancels |
| Insert, Command, Search | Ctrl+u deletes backward to the logical line start | | Insert, Command, Search | Ctrl+u deletes backward to the logical line start |
| Insert, Command, Search (macOS) | Option+Backspace deletes backward by word; Command+Backspace deletes backward to the logical line start | | Insert, Command, Search (macOS) | Option+Backspace deletes backward by word; Command+Backspace deletes backward to the logical line start |
@@ -74,7 +74,7 @@ With an enabled server that provides completion, Ctrl+n in Insert requests candi
Every candidate is applied from that same immutable original document, not on top of the previous candidate. A candidate's primary replacement and additional text edits are one atomic change. Other keys close the list without accepting it: plain-text completion retains ordinary typing, newline, and configured Tab insertion; snippet completion keeps its argument navigation described below. Escape returns to Normal retaining the displayed text. There is no automatic popup. Empty, unsupported, or invalid results leave the document unchanged and report a message. Every candidate is applied from that same immutable original document, not on top of the previous candidate. A candidate's primary replacement and additional text edits are one atomic change. Other keys close the list without accepting it: plain-text completion retains ordinary typing, newline, and configured Tab insertion; snippet completion keeps its argument navigation described below. Escape returns to Normal retaining the displayed text. There is no automatic popup. Empty, unsupported, or invalid results leave the document unchanged and report a message.
Cycling creates no separate undo entries. Only the final chosen edits join the existing Insert undo group; choosing Original text adds no completion edit. Undo restores that group, and dot repeats the final resolved completion edits rather than requests or intermediate candidates. The list stays inside the document text workspace, does not cover the gutter or explorer, and never takes the hardware cursor. Wheel input over it is consumed without choosing a candidate. Cycling creates no separate undo entries. Only the final chosen edits join the existing Insert undo group; choosing Original text adds no completion edit. Undo restores that group, and dot repeats the final resolved completion edits rather than requests or intermediate candidates. The list stays inside the document text workspace, does not cover the gutter or explorer, and never takes the primary cursor. Wheel input over it is consumed without choosing a candidate.
Snippet candidates expand the full template immediately, including parentheses and argument defaults. The first numbered argument is highlighted; typing replaces it, then continues within that argument. Repeated occurrences of the same argument update together, without broadcasting to secondary carets. Backspace/Delete stay within the active argument; Enter can insert a newline there. **Tab** moves forward and **Shift+Tab** backward in ascending tabstop order. Replacing an enclosing argument removes its nested arguments, not adjacent independent ones. Reaching the final `$0` position ends navigation, so subsequent Tab uses the configured spaces-or-tab insertion. Escape, ordinary cursor motion, paste, commands, and focus/buffer changes also end navigation without removing the expanded text. Snippet candidates expand the full template immediately, including parentheses and argument defaults. The first numbered argument is highlighted; typing replaces it, then continues within that argument. Repeated occurrences of the same argument update together, without broadcasting to secondary carets. Backspace/Delete stay within the active argument; Enter can insert a newline there. **Tab** moves forward and **Shift+Tab** backward in ascending tabstop order. Replacing an enclosing argument removes its nested arguments, not adjacent independent ones. Reaching the final `$0` position ends navigation, so subsequent Tab uses the configured spaces-or-tab insertion. Escape, ordinary cursor motion, paste, commands, and focus/buffer changes also end navigation without removing the expanded text.
@@ -126,7 +126,7 @@ System clipboard publication must succeed before any deletion. Failure preserves
## Selections and multi-caret editing ## Selections and multi-caret editing
Editing uses an ordered selection set with one primary target. Cursor and word motions apply to every target; Visual motions keep each target's anchor and move its active endpoint. Whole combining sequences and ZWJ emoji remain intact. `v` selects characterwise, inclusive of endpoints; `V` selects every complete row touched by the endpoints, including blank and empty final rows. Switching between `v` and `V` retains endpoints; pressing the current shape's selector again cancels. Read-only ranges are end-exclusive. Characterwise `$`/End can select the newline after a non-final row; selected newlines appear as a blue blank cell after the line. Linewise highlighting covers the copied text, including trailing whitespace, plus one blank cell for each copied newline; unused viewport space and the gutter remain unhighlighted. The status reads VISUAL LINE. Blue highlighting overrides yellow search highlighting; secondary carets are magenta, including on empty lines and at line ends. Only the primary owns the hardware cursor and viewport. The status line counts all targets, including offscreen targets. Editing uses an ordered selection set with one primary target. Cursor and word motions apply to every target; Visual motions keep each target's anchor and move its active endpoint. Whole combining sequences and ZWJ emoji remain intact. `v` selects characterwise, inclusive of endpoints; `V` selects every complete row touched by the endpoints, including blank and empty final rows. Switching between `v` and `V` retains endpoints; pressing the current shape's selector again cancels. Read-only ranges are end-exclusive. Characterwise `$`/End can select the newline after a non-final row; selected newlines appear as a blue blank cell after the line. Linewise highlighting covers the copied text, including trailing whitespace, plus one blank cell for each copied newline; unused viewport space and the gutter remain unhighlighted. The status reads VISUAL LINE. Blue highlighting overrides yellow search highlighting; secondary carets follow the mode palette (magenta in Visual), including on empty lines and at line ends. The primary software cursor overlays these text colors and alone drives the viewport; its shape and mode colors are [configurable](configuration.md#preferences). The status line counts all targets, including offscreen targets.
Ctrl+k/Ctrl+e clone each target to the adjacent row, preserving preferred terminal-cell columns (tabs use four-cell stops); Visual cloning preserves both endpoints. Duplicate carets and truly overlapping selections merge; adjacent ranges remain distinct. Visual Ctrl+l creates one Normal caret at the first nonblank of each touched row. Visual `I`/`A` similarly create one Insert caret per touched row at first nonblank/end. Occurrence actions use the primary selection as a literal, potentially multiline, grapheme-aligned query: Ctrl+n adds the next non-overlapping occurrence and wraps; Ctrl+s moves only the primary to that occurrence; Ctrl+a selects all occurrences. Ctrl+k/Ctrl+e clone each target to the adjacent row, preserving preferred terminal-cell columns (tabs use four-cell stops); Visual cloning preserves both endpoints. Duplicate carets and truly overlapping selections merge; adjacent ranges remain distinct. Visual Ctrl+l creates one Normal caret at the first nonblank of each touched row. Visual `I`/`A` similarly create one Insert caret per touched row at first nonblank/end. Occurrence actions use the primary selection as a literal, potentially multiline, grapheme-aligned query: Ctrl+n adds the next non-overlapping occurrence and wraps; Ctrl+s moves only the primary to that occurrence; Ctrl+a selects all occurrences.
@@ -192,7 +192,17 @@ Submitting an empty search repeats the last query. Esc cancels without moving th
`:` opens compact floating command input titled **Command**; `/` opens input titled **Search** for buffer text search. Titles sit at the top-left border, replacing the displayed leading `:` / `/`. Both input fields have one column of padding on each side. Enter submits and Escape cancels using the existing modal rules. Long Unicode input scrolls horizontally without changing buffer text. Both windows default to the workspace's bottom-left edge, directly above the bottom bar when visible; their [anchors can be configured independently](configuration.md#command-and-search-popup-anchors). They temporarily hide operation notifications and never reserve a permanent prompt/footer row. Tiny windows omit borders/titles or padding when needed to keep input usable. `:` opens compact floating command input titled **Command**; `/` opens input titled **Search** for buffer text search. Titles sit at the top-left border, replacing the displayed leading `:` / `/`. Both input fields have one column of padding on each side. Enter submits and Escape cancels using the existing modal rules. Long Unicode input scrolls horizontally without changing buffer text. Both windows default to the workspace's bottom-left edge, directly above the bottom bar when visible; their [anchors can be configured independently](configuration.md#command-and-search-popup-anchors). They temporarily hide operation notifications and never reserve a permanent prompt/footer row. Tiny windows omit borders/titles or padding when needed to keep input usable.
Command input works in both Normal and Visual modes, including read-only diff panes. The original selection stays highlighted while typing. Enter dispatches with its ranges, direction, and linewise shape intact; individual commands decide whether to consume or retain them. Escape returns to the original Visual selection without executing. For example, select lines with `V`, then type `:diff.comment` and press Enter—no command palette is needed. `config.core.command` can also be bound in Visual mode. Up/Ctrl+k recalls older entries; Down/Ctrl+j recalls newer entries and restores the unfinished input after the newest. Navigation stops at either end without executing a command or moving search targets. Recalled text is editable without changing stored entries. Command and search histories are separate, shared across buffers/panes/tabs, and retained only for this editor session, up to 100 entries each. Enter records nonempty submissions, including unknown commands and searches with no matches; consecutive duplicates are skipped. Empty submissions and cancelled drafts are not recorded. Ctrl+j no longer submits Command/Search input; use Enter.
Tab replaces the command-name prefix with the first case-sensitive match in alphabetical order; repeated presses cycle forward through the same matches and wrap. Shift+Tab cycles backward, starting at the last match when no cycle is active. Switching direction retains the original prefix. Candidates include canonical core commands, shorthand aliases, and commands from currently enabled plugins. Empty input cycles all command names. Typing, deleting, or browsing history starts a fresh completion cycle. Completion only changes the prompt: Enter still executes, and Escape cancels. Arguments (including paths) are not completed; Tab and Shift+Tab leave them unchanged. Search-mode Tab remains literal text. Rebind or disable forward/backward completion with `config.core.command_complete` / `config.core.command_complete_previous` in Command mode.
Command input works in both Normal and Visual modes, including read-only diff panes. The original selection stays highlighted while typing. Enter dispatches with its ranges, direction, and linewise shape intact; individual commands decide whether to consume or retain them. Escape returns to the original Visual selection without executing. For example, select lines with `V`, then type `:comment` and press Enter—no command palette is needed. `config.core.command` can also be bound in Visual mode.
Use `:!COMMAND` (for example `:!pwd` or `:!echo "$PATH"`) to run shell commands asynchronously while continuing to edit. Vamp runs `$SHELL -c COMMAND`, falling back to `/bin/sh`, in the editor's project directory with the inherited environment and closed stdin. Shell quoting, pipes, and redirection work normally; interactive terminal programs are not supported. Commands have the same permissions as Vamp, including file and network access. Submit additional `:!` commands while others are running to execute them concurrently.
While running, the default top bar uses one independently styled segment immediately left of the battery for all pending commands. Each entry shows the first three command graphemes, `...` when truncated, and an animated spinner after a 200 ms delay. Completion opens a read-only floating window with the command, exit status (or launch error), stdout, and separately marked stderr. Further completed results queue without replacing the visible window; Enter/Escape dismisses the current result and opens the next, or resumes the underlying editing mode when none remain. Arrows/Ctrl+h/j/k/l and the mouse wheel scroll; Home resets and End goes to the last line. Output is decoded as UTF-8 with replacements; controls are displayed as text, never interpreted as terminal escapes. Capture retains up to 4 MiB per stream with an explicit truncation notice. There is no command timeout; exiting Vamp kills all running command process groups, and background jobs in each group are also stopped when their command exits. Shell execution currently requires Unix.
In the shell output window, your configured `config.core.word_wrap.toggle` binding toggles soft wrapping, including leader and multi-key sequences. Bindings use Normal mode while the popup is focused; there is no additional default shortcut. This does not change output bytes or the source buffer's wrap setting. Wrapping starts off for each command; toggling resets scrolling to the start. With wrapping on, vertical scrolling follows visual rows and horizontal scrolling is disabled.
The latest reported operation result appears in a non-focusable popup at the workspace's bottom-right, just above the bottom bar when visible, with one column of inner padding on each side when space permits. It remains until dismissed or replaced, with no timer. In Normal mode, the first Escape dismisses it without collapsing carets; a subsequent Escape retains ordinary Normal behavior. Existing modal windows, focused explorers/diff selectors, and pending prefixes retain Escape priority. New operation messages reopen the popup; routine successful buffer switches do not duplicate the buffer/path information already shown in the bar. The latest reported operation result appears in a non-focusable popup at the workspace's bottom-right, just above the bottom bar when visible, with one column of inner padding on each side when space permits. It remains until dismissed or replaced, with no timer. In Normal mode, the first Escape dismisses it without collapsing carets; a subsequent Escape retains ordinary Normal behavior. Existing modal windows, focused explorers/diff selectors, and pending prefixes retain Escape priority. New operation messages reopen the popup; routine successful buffer switches do not duplicate the buffer/path information already shown in the bar.
@@ -240,7 +250,7 @@ Vertical wheel input over a hover popup or floating/docked file explorer scrolls
## Word wrap ## Word wrap
Use `:word_wrap.toggle`, the **Toggle word wrap** palette action, or `config.core.word_wrap.toggle` in a custom binding. Wrapping defaults to **off** for new buffers and has **no default keybinding**. Each ordinary pane retains its own setting for each buffer; toggling does not edit text or enter undo history. Use `:word_wrap.toggle`, the **Toggle word wrap** palette action, or `config.core.word_wrap.toggle` in a custom binding. Wrapping defaults to **off** for new source buffers and **on** for local review buffers, with **no default keybinding**. Each ordinary pane retains its own setting for each buffer; toggling does not edit text or enter undo history.
Toggling preserves the source caret and anchors a visible caret to its previous screen row in ordinary and diff buffers. File boundaries clamp the anchor when there are not enough preceding rows. Horizontal position follows the reflowed text. Toggling preserves the source caret and anchors a visible caret to its previous screen row in ordinary and diff buffers. File boundaries clamp the anchor when there are not enough preceding rows. Horizontal position follows the reflowed text.
@@ -248,9 +258,9 @@ Text wraps at word boundaries where possible, with grapheme-safe breaks for long
## Current-line Git blame ## Current-line Git blame
Inline blame is enabled by default in ordinary live file buffers. The primary caret's line shows its author, commit summary, and short commit ID after the document text; changed or newly inserted tracked-file lines show **Uncommitted**, including unsaved edits. Git-backed jj repositories use the working-copy revision's single parent, not a potentially stale Git HEAD. Merge working copies with multiple parents omit attribution. Inline blame is enabled by default in live file buffers and on the focused side of split/unified diffs. The primary caret's line shows its author, commit summary, and short commit ID after the document text; changed or newly inserted tracked-file lines show **Uncommitted**, including unsaved edits. Live Git-backed jj buffers use the working-copy revision's single parent, not a potentially stale Git HEAD. Merge working copies with multiple parents omit live attribution.
Loading happens in the background without network access or changing repository refs/checkouts. Attribution is loaded once for the active buffer's in-memory contents; navigation then selects cached lines immediately, and movement during a cold load does not restart it. Editing, saving, reloading, changing source buffers, regaining terminal focus, or toggling blame invalidates the cached attribution. Missing, untracked, unborn, and non-repository files have no indicator; immutable historical buffers and comparison views do not receive live-file attribution. The indicator is hidden when the caret is offscreen or the full label cannot fit; wrapped lines use their final visible continuation without covering text, selections, or the caret. Popups retain their normal layering. Loading happens in the background without network access or changing repository refs/checkouts. Attribution is loaded once for the active buffer's in-memory contents; navigation then selects cached lines immediately, and movement during a cold load does not restart it. Editing, saving, reloading, changing source buffers, regaining terminal focus, or toggling blame invalidates the cached attribution. Historical sides use their captured commit and side-specific path, including renamed or deleted files no longer on disk; historical tabs retain that source and any privately fetched objects after the comparison closes. Frozen Working sides use their captured parent baseline rather than a later checkout. Missing, untracked, unborn, and non-repository live files have no indicator. Long attribution labels clip to the spare cells after the source text instead of disappearing entirely. The indicator is hidden when the caret is offscreen or no inline space remains; wrapped lines use their final visible continuation without covering text, selections, or the caret. Popups retain their normal layering.
Use `:git_blame.toggle`, the palette action, or remappable `config.core.git_blame.toggle`; there is no default keybinding. Set `config.git_blame = false` to disable it at startup. Disabling stops the worker and clears the label without changing text or undo history. Use `:git_blame.toggle`, the palette action, or remappable `config.core.git_blame.toggle`; there is no default keybinding. Set `config.git_blame = false` to disable it at startup. Disabling stops the worker and clears the label without changing text or undo history.
@@ -287,6 +297,9 @@ Pane-focus bindings also work while the ordinary file explorer or picker has foc
Floating windows overlay the entire editor, above all workspace panes. File pickers, revision selectors, hover documentation, and completion lists are not clipped to the focused pane; completion remains anchored to its caret. Docked exploration reserves space at the editor edge before the remaining workspace is split into panes. The changed-file explorer follows the same layout, on the editor's right edge. Floating windows overlay the entire editor, above all workspace panes. File pickers, revision selectors, hover documentation, and completion lists are not clipped to the focused pane; completion remains anchored to its caret. Docked exploration reserves space at the editor edge before the remaining workspace is split into panes. The changed-file explorer follows the same layout, on the editor's right edge.
On light terminals that answer color queries, selected explorer rows use a pale shade of the terminal background, preserving filename, directory and repository-status foregrounds when focused or unfocused. Dark terminals and unavailable color reports retain the ANSI blue/dark-gray selection backgrounds. Theme changes update visible and parked panes.
Diff comparisons reuse the split geometry and directional-focus engine. Option/Alt navigation moves between Before, After, and Files first, then to a neighboring workspace pane when there is no internal target in that direction. Splitting a diff text view creates an ordinary sibling showing the same buffer; historical snapshots remain read-only, including after the comparison closes. Diff comparisons reuse the split geometry and directional-focus engine. Option/Alt navigation moves between Before, After, and Files first, then to a neighboring workspace pane when there is no internal target in that direction. Splitting a diff text view creates an ordinary sibling showing the same buffer; historical snapshots remain read-only, including after the comparison closes.
## Tabs ## Tabs
@@ -377,16 +390,20 @@ The project root is the working directory captured at startup. Scanning uses the
Run `:diff` in Normal mode, choose **Before**, then **After**, and press Enter to compare. The command palette also exposes **Diff**; there is no default keybinding. Repository discovery starts at the active file's directory, or the startup project root for an unnamed buffer, and prefers jj over Git. Run `:diff` in Normal mode, choose **Before**, then **After**, and press Enter to compare. The command palette also exposes **Diff**; there is no default keybinding. Repository discovery starts at the active file's directory, or the startup project root for an unnamed buffer, and prefers jj over Git.
The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / Branches / Tags** for Git. Both Before and After support case-insensitive fuzzy search by revision/change ID, commit ID, reference name, or description: `f1205pas` can find `sm/fin-1205-pilot-assignment-schema`. Space-separated tokens must all match, but can match different fields in any order; a single token cannot span unrelated fields. Exact, prefix, and contiguous matches rank ahead of scattered subsequences; otherwise equal matches favor names/IDs over descriptions, word boundaries, and tighter spans. Ties retain catalogue order, and an empty or whitespace-only query restores the original order. **Working checkout** stays first in every category, even when it does not match the query. As Before it is a disk snapshot, including eligible untracked files. As After it includes unsaved regular-text buffers and enables live editing; opening a comparison never saves them. The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / Branches / Tags** for Git. Both jj tabs use flat lists without ancestry lanes. **Revisions** puts the short change ID and commit description on the first line; bookmarks, author, date, commit ID, and textual **[checkout]** / **[immutable]** badges appear on a dimmed second line. **Bookmarks** puts the name first, with the commit description underneath. Local and remote-tracking bookmarks remain individually selectable, including multiple names on one revision, preserving their symbolic follow behavior. Narrow windows omit revision metadata and use compact single-line entries.
New comparisons default to **Branch changes**: Before becomes the unique merge base of the selected Before and After revisions, and After stays the selected tip. Changes exclusive to the Before branch are excluded. The Before border identifies both selected names and the immutable merge-base commit ID. **Snapshots** compares the two selected snapshots directly. **Ctrl+b** toggles the mode, also available as **`:diff.mode.toggle`**, the palette action, and remappable **`config.core.diff.mode.toggle`**. Toggling an opened comparison recomputes using the original selected endpoints. Selecting **Working checkout** on either side immediately forces **Snapshots**; Branch changes is unavailable while it is selected. Missing or multiple merge bases produce an explicit, recoverable selector error rather than silently falling back; choose Snapshots to compare such histories. jj's synthetic root is the empty ancestor of every jj history. Both stages support case-insensitive fuzzy search by revision/change ID, commit ID, reference name, or description: `f1205pas` can find `sm/fin-1205-pilot-assignment-schema`. Space-separated tokens must all match, but can match different fields; a token cannot span unrelated fields. jj keeps catalogue order while filtering. Git ranks exact, prefix, and contiguous matches ahead of scattered subsequences, with names/IDs ahead of descriptions and catalogue order breaking ties. An empty query restores catalogue order. After choosing Before, its revision carries a **[Before]** badge instead of a separate summary row. It remains included through search and category changes, but scrolls normally.
The separate checkout choice stays first in every category regardless of the query. It is **Checkout snapshot** during Before selection: a frozen disk snapshot including eligible untracked files. During After selection it is **Follow checkout** when `config.diff_follow_working_copy` is enabled, otherwise **Checkout snapshot**. Either After choice includes unsaved regular-text buffers and enables live editing; opening a comparison never saves them. Unlike this live-files choice, selecting the recorded checkout revision in Revisions pins that revision.
New comparisons default to **Branch changes**: Before becomes the unique merge base of the selected Before and After revisions, and After stays the selected tip. Changes exclusive to the Before branch are excluded. The Before border identifies both selected names and the immutable merge-base commit ID. **Snapshots** compares the two selected snapshots directly. **Ctrl+b** toggles the mode, also available as **`:diff.mode.toggle`**, the palette action, and remappable **`config.core.diff.mode.toggle`**. Toggling an opened comparison recomputes using the original selected endpoints. Selecting **Checkout snapshot** or **Follow checkout** immediately forces **Snapshots**; Branch changes is unavailable while either is selected. Missing or multiple merge bases produce an explicit, recoverable selector error rather than silently falling back; choose Snapshots to compare such histories. jj's synthetic root is the empty ancestor of every jj history.
| Selector input | Effect | | Selector input | Effect |
| --- | --- | | --- | --- |
| Tab / Shift+Tab | Next / previous category | | Tab / Shift+Tab | Next / previous category |
| Up / Down, Ctrl+k / Ctrl+j | Previous / next entry | | Up / Down, Ctrl+k / Ctrl+j | Previous / next entry |
| Backspace / Ctrl+u | Remove a query grapheme / clear query | | Backspace / Ctrl+u | Remove a query grapheme / clear query |
| Ctrl+b | Toggle Branch changes / Snapshots (Working checkout requires Snapshots) | | Ctrl+b | Toggle Branch changes / Snapshots (checkout choices require Snapshots) |
| Enter | Choose the entry | | Enter | Choose the entry |
| Esc | Close either selector stage, cancel outstanding work, and restore the source buffer without discarding edits | | Esc | Close either selector stage, cancel outstanding work, and restore the source buffer without discarding edits |
@@ -394,7 +411,23 @@ Printable input belongs to the focused query, including `:`, `_`, and `|`; Norma
The selector initially lists local revisions and known tracking refs **without contacting remotes**. **Load refs from origin** (or another configured remote) in Bookmarks/Branches/Tags lists advertised references, including refs never fetched locally, without advancing the selection stage. Selecting a reference such as `some-bookmark@origin` reuses available content or fetches the missing selected reference into a temporary comparison repository. **Load history from origin** in Revisions/Commits explicitly imports searchable remote history. Network operations use the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout. The selector initially lists local revisions and known tracking refs **without contacting remotes**. **Load refs from origin** (or another configured remote) in Bookmarks/Branches/Tags lists advertised references, including refs never fetched locally, without advancing the selection stage. Selecting a reference such as `some-bookmark@origin` reuses available content or fetches the missing selected reference into a temporary comparison repository. **Load history from origin** in Revisions/Commits explicitly imports searchable remote history. Network operations use the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout.
**Pull requests:** run **`:pull_requests [remote]`**, use the palette's `pull_requests` action, or bind **`config.core.pull_requests`**. The floating picker has **Open / Closed / All** tabs; Closed includes merged requests. Tab/Shift+Tab changes category, typing fuzzy-searches its number, title, author, state, and branches, and Enter immediately opens the selected request in **Branch changes** mode using its immutable head and merge base. Escape restores the source buffer. A failed list can be retried with Enter. The default remote is `origin`, or the sole configured remote; specify a name when there are multiple remotes without `origin`. **Pull requests:** run **`:pull_requests [remote]`**, use the palette's `pull_requests` action, or bind **`config.core.pull_requests`**. The floating picker has **Open / Closed / Drafts / All** tabs. Open excludes drafts; Drafts contains open draft requests; Closed includes merged requests and closed drafts. Tab/Shift+Tab changes category, typing fuzzy-searches its number, title, author, state, and branches, and Enter immediately opens the selected request in **Branch changes** mode using its immutable head and merge base. Escape restores the source buffer. A failed list can be retried with Enter. The default remote is `origin`, or the sole configured remote; specify a name when there are multiple remotes without `origin`.
Each pull request's metadata shows added/deleted line counts after its state, before the branch names: `author · open · +123 -45 · topic → main`. Zero counts are shown explicitly; missing, denied, or failed statistics show `size unavailable` without preventing selection. Counts come from one batched read-only statistics query per nonempty GitHub/GitLab page ([GitHub](https://docs.github.com/en/graphql/reference/pulls), [GitLab](https://docs.gitlab.com/api/graphql/reference/#diffstatssummary)); [Gitea](https://docs.gitea.com/api/operations/repo-list-pull-requests/) supplies them directly in its list response. GitHub statistics require API authentication even for public repositories. Listing does not fetch source diffs.
Immediately before `#number`, **🙏 marks an outstanding personal review request** and **👍 marks your active approval**, separately from the `>` selection arrow. A review request takes precedence when both apply, including a re-review request after an earlier approval. “You” means the account authenticated to that forge's API—not the repository's Git author or SSH identity. Other users' and team-only requests do not count; completed or removed requests lose 🙏.
A later request for changes or dismissal removes 👍; comment-only and pending reviews do not replace an existing approval. The mark is not the PR's overall approval decision or a guarantee that you reviewed its current head; the forge controls approval resets after new commits. Customize either symbol with [`config.pull_request_review_requested_symbol` and `config.pull_request_approved_symbol`](configuration.md); an empty string hides that symbol. Multi-cell symbols remain aligned, and decorations are not part of searchable PR labels.
**Ctrl+s** opens sorting settings in the floating picker. The default comparison order is **Requested from me → Updated at → Approved by me → Created at**, with true statuses and newest timestamps first. Up/Down or the configured selection bindings navigate. Press **1–4** to assign the highlighted criterion an absolute precedence; it moves to that position and stays highlighted. Occupied explicit ranks shift forward (wrapping to a free slot); unnumbered criteria fill the remaining slots in their default relative order. Only explicitly assigned ranks display numbers. **Backspace** clears an explicit rank; **Left/Right** toggles oldest/newest first for timestamp criteria. Comparisons are lexicographic, with higher PR number as the final tie-breaker. Fuzzy text still determines which requests match; these settings determine their order.
**Ctrl+f** opens review filter checkboxes. **Space** toggles the highlighted bucket: **Requested from me**, **Approved by me**, or **Other / unavailable**. Checked buckets combine with OR, then intersect with the category and text query. All checked includes every review state; none checked produces no matches. These are personal states, not the PR's team approval threshold: teammates' outstanding requests do not cancel your approval. A new request for your own review takes precedence over your earlier approval. Unavailable metadata remains Other, not a claim that the PR has never been reviewed.
**Enter** applies either settings view; **Escape** discards its draft and returns to the PR list. Applied settings preserve the selected request where possible and carry across tabs and picker reopenings. Sorting persists across sessions; filters are session-local and restart from `config.pull_request_filters` or all buckets when unset. Previously saved filters are ignored. The picker displays its active review buckets whenever any are excluded. Category counts remain loaded-category totals, and partial-search notices still apply. Settings use already-loaded metadata without additional API requests. Explicit [configuration overrides](configuration.md#preferences) win again at startup; the settings view indicates when this applies.
Review-state reads reuse the same API credentials as listing. GitHub matches the current list's [requested reviewers](https://docs.github.com/en/rest/pulls/review-requests#list-requested-reviewers-for-a-pull-request) and filters published [reviews by author and decision state](https://docs.github.com/en/graphql/reference/pulls#pullrequest). GitLab filters approvals by the authenticated approver and reads individual reviewer [interaction states](https://docs.gitlab.com/api/graphql/reference/) in separate bounded, paginated batches; unsupported or denied reviewer-state fields do not discard known approvals or size metadata. Gitea reuses [paginated review reads per PR](https://docs.gitea.com/api/operations/repo-list-pull-reviews/) for both states, so its pages can take longer to load.
Anonymous, denied, missing, or failed state lookups leave the corresponding indicator unavailable without preventing browsing. A blank is not proof that no request or approval exists. Reopen the picker to fetch current review state. No approval, review request, or other remote write is performed.
GitHub, GitLab merge requests, and Gitea are supported. API reads require `curl`; public repositories work anonymously. Authentication uses `GH_TOKEN`/`GITHUB_TOKEN` for github.com, `GH_ENTERPRISE_TOKEN`/`GITHUB_ENTERPRISE_TOKEN` for GitHub Enterprise, `GITLAB_TOKEN` for GitLab, and `GITEA_TOKEN` for Gitea. With no matching environment token, GitHub/GitLab reuse `gh auth token --hostname HOST` / `glab auth token --hostname HOST` when available. Tokens are supplied to curl on stdin, not in subprocess arguments, and sent only to the HTTPS API; redirects are not followed. Git object fetching uses the configured remote's existing Git/SSH credentials separately. GitHub, GitLab merge requests, and Gitea are supported. API reads require `curl`; public repositories work anonymously. Authentication uses `GH_TOKEN`/`GITHUB_TOKEN` for github.com, `GH_ENTERPRISE_TOKEN`/`GITHUB_ENTERPRISE_TOKEN` for GitHub Enterprise, `GITLAB_TOKEN` for GitLab, and `GITEA_TOKEN` for Gitea. With no matching environment token, GitHub/GitLab reuse `gh auth token --hostname HOST` / `glab auth token --hostname HOST` when available. Tokens are supplied to curl on stdin, not in subprocess arguments, and sent only to the HTTPS API; redirects are not followed. Git object fetching uses the configured remote's existing Git/SSH credentials separately.
@@ -402,9 +435,9 @@ Standard GitHub/GitLab/Gitea hosts and Codeberg are detected from HTTPS or SSH r
SSH aliases are resolved with `ssh -G` before provider detection and API authentication, honoring the effective `HostName` and user-specific `Match` settings. SSH ports do not become HTTPS API ports. Git fetches retain the original remote URL, alias, and SSH identity configuration. A failed API load shows its error without also claiming that the search has no matches; the empty-result message appears only after a successful load. SSH aliases are resolved with `ssh -G` before provider detection and API authentication, honoring the effective `HostName` and user-specific `Match` settings. SSH ports do not become HTTPS API ports. Git fetches retain the original remote URL, alias, and SSH identity configuration. A failed API load shows its error without also claiming that the search has no matches; the empty-result message appears only after a successful load.
Open and Closed fetch their first pages concurrently, newest-first, and display each page immediately. Subsequent pages load in the background, with the active category dispatched first; loaded rows remain selectable. Pages request 50 entries. GitLab Closed combines separate closed and merged requests (up to 100 entries per batch). All merges the two catalogs without a third fetch stream, deduplicates requests, and sorts by creation time; background updates preserve the selected PR's identity. Open (including drafts) and Closed fetch their first pages concurrently, newest-first, and display each page immediately. Open and Drafts filter the same catalog, sharing its loading, completion, and retry state without extra requests. Subsequent pages load in the background, with the active category's stream dispatched first; loaded rows remain selectable. Pages request 50 entries. GitLab Closed combines separate closed and merged requests (up to 100 entries per batch). All includes open, draft, closed, and merged requests from the two streams, deduplicates requests, and sorts by creation time; background updates preserve the selected PR's identity.
Search filters loaded entries locally and explicitly indicates partial results while more pages remain. A later-page error retains loaded rows; **Ctrl+r** retries failed categories (the active category, or both from All). Enter selects a loaded row; in an empty failed category it retries loading. Escape cancels outstanding work. Selection stops listing and only then retrieves that PR's historical metadata and preserved head reference, so GitLab detail reads and Gitea ref lookups do not delay the catalog. Search filters loaded entries locally and explicitly indicates partial results while more pages remain. A later-page error retains loaded rows; **Ctrl+r** retries failed fetch streams (the active category's stream, or both from All). Enter selects a loaded row; in an empty failed category it retries loading. Escape cancels outstanding work. Selection stops listing and only then retrieves that PR's historical metadata and preserved head reference, so GitLab detail reads and Gitea ref lookups do not delay the catalog.
Fork heads are fetched through the target repository's PR refs into the existing private comparison store, without changing active refs, jj operations, or checkout files. Closed/merged requests retain historical bases and heads rather than comparing against today's branch tips. If the server no longer retains the required commits, opening the request reports an error instead of displaying a different diff. Review submission and comments are separate, not part of this picker. Fork heads are fetched through the target repository's PR refs into the existing private comparison store, without changing active refs, jj operations, or checkout files. Closed/merged requests retain historical bases and heads rather than comparing against today's branch tips. If the server no longer retains the required commits, opening the request reports an error instead of displaying a different diff. Review submission and comments are separate, not part of this picker.
@@ -414,9 +447,9 @@ Comparison discovery builds the changed-path/status catalog first. File text, no
Conflicted jj revisions also discover files without reading every blob. Ordinary files and symbolic links retain their Git contents; selected conflicted files show jj's rendered conflict projection. Unchanged links and jj's internal merge-tree metadata do not appear as changes. Viewing a conflict does not resolve it or modify the revision. Conflicted jj revisions also discover files without reading every blob. Ordinary files and symbolic links retain their Git contents; selected conflicted files show jj's rendered conflict projection. Unchanged links and jj's internal merge-tree metadata do not appear as changes. Viewing a conflict does not resolve it or modify the revision.
**Working checkout After follows forward checkouts automatically**, checked asynchronously once per second, including inactive tabs. A descendant jj change or Git HEAD rebuilds the changed-file catalog and captures current working bytes plus eligible unsaved buffers, while keeping the original Before frozen. jj tracking uses change IDs, so snapshot/description rewrites of the same change do not advance the endpoint or prevent following its later descendants. Same, backward, and unrelated checkouts do not rebuild the catalog; live-buffer editing and external reload still work normally. Historical After never follows. Set `config.diff_follow_working_copy = false` to disable following. **Follow checkout After follows descendant checkouts automatically**, checked asynchronously once per second, including inactive tabs. A descendant jj change or Git HEAD rebuilds the changed-file catalog and captures current working bytes plus eligible unsaved buffers, while keeping the original Before frozen. jj tracking uses change IDs, so snapshot/description rewrites of the same change do not advance the endpoint or prevent following its later descendants. Same, backward, and unrelated checkouts do not rebuild the catalog; live-buffer editing and external reload still work normally. Historical After never follows. Set `config.diff_follow_working_copy = false` to disable following; the picker then calls this choice **Checkout snapshot**.
**Selected jj bookmarks follow their locally known targets automatically**, checked on the same one-second interval, including inactive tabs and with `config.diff_follow_working_copy = false`. Local bookmarks and known remote-tracking bookmarks retain their names; explicit revision selections remain pinned. A changed commit ID, including an amendment of the same change ID, rebuilds the comparison. Branch changes also recomputes the merge base. Unrelated endpoints stay frozen, except for the existing Working After forward-follow behavior. Deleted or conflicted bookmarks keep the last successful comparison visible with an error; a later successful check clears the error. These checks never fetch, contact remotes, or snapshot jj's working copy; privately loaded remote history and advertised remote selections remain frozen. **Selected jj bookmarks follow their locally known targets automatically**, checked on the same one-second interval, including inactive tabs and with `config.diff_follow_working_copy = false`. Local bookmarks and known remote-tracking bookmarks retain their names; explicit revision selections remain pinned. A changed commit ID, including an amendment of the same change ID, rebuilds the comparison. Branch changes also recomputes the merge base. Unrelated endpoints stay frozen, except for the existing Follow checkout After behavior. Deleted or conflicted bookmarks keep the last successful comparison visible with an error; a later successful check clears the error. These checks never fetch, contact remotes, or snapshot jj's working copy; privately loaded remote history and advertised remote selections remain frozen.
Deleted or conflicted bookmarks unrelated to the selected endpoints do not block opening or refreshing a comparison. Deleted or conflicted bookmarks unrelated to the selected endpoints do not block opening or refreshing a comparison.
@@ -424,7 +457,7 @@ Deleted or conflicted bookmarks unrelated to the selected endpoints do not block
Refresh retains the selected path when still present, explorer query/view/visibility, logical text-side focus, and split/unified/orientation/wrap choices; moving a Working or bookmark endpoint clears review marks, while pinned checkout-only transitions retain them. Removed selections fall back to the first changed file. Bookmark replacements preserve/clamp cursor and scroll positions as replacement content loads. Failed refreshes keep the previous comparison visible. Superseded file loads and LSP navigation cannot apply to the new comparison; snapshots still displayed in other panes or tabs remain read-only. Refresh retains the selected path when still present, explorer query/view/visibility, logical text-side focus, and split/unified/orientation/wrap choices; moving a Working or bookmark endpoint clears review marks, while pinned checkout-only transitions retain them. Removed selections fall back to the first changed file. Bookmark replacements preserve/clamp cursor and scroll positions as replacement content loads. Failed refreshes keep the previous comparison visible. Superseded file loads and LSP navigation cannot apply to the new comparison; snapshots still displayed in other panes or tabs remain read-only.
The changed-file explorer is **docked on the right**. In the default split layout, modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain both source sides. Before and unmatched pinned After snapshots are read-only. **Working checkout** or a checkout-matched pinned After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. Editable After supports native edits, completion, undo/redo, and `:w`. Removed files, rename-only files, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits. The changed-file explorer is **docked on the right**. In the default split layout, modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain both source sides. Before and unmatched pinned After snapshots are read-only. A **Follow checkout / Checkout snapshot After** or a checkout-matched pinned After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. Editable After supports native edits, completion, undo/redo, and `:w`. Removed files, rename-only files, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits.
Comparisons start in **split, vertical (left/right)** view by default. In Normal mode, **Space v** cycles split/unified; **Space s** switches a split between vertical (left/right) and horizontal (above/below, Before above After). Canonical commands are `:diff.view.toggle` and `:diff.orientation.toggle`; both are available in the palette and as remappable `config.core.diff.view.toggle` / `config.core.diff.orientation.toggle` actions. Configure new-comparison defaults with `config.diff_view_mode = "split"` or `"unified"` and `config.diff_split_orientation = "vertical"` or `"horizontal"`. Current choices survive file and tab switches; closing and reopening uses the configured defaults. Orientation stays unchanged while unified is active. Comparisons start in **split, vertical (left/right)** view by default. In Normal mode, **Space v** cycles split/unified; **Space s** switches a split between vertical (left/right) and horizontal (above/below, Before above After). Canonical commands are `:diff.view.toggle` and `:diff.orientation.toggle`; both are available in the palette and as remappable `config.core.diff.view.toggle` / `config.core.diff.orientation.toggle` actions. Configure new-comparison defaults with `config.diff_view_mode = "split"` or `"unified"` and `config.diff_split_orientation = "vertical"` or `"horizontal"`. Current choices survive file and tab switches; closing and reopening uses the configured defaults. Orientation stays unchanged while unified is active.
@@ -432,7 +465,9 @@ Comparisons start in **split, vertical (left/right)** view by default. In Normal
Diff metadata lives in the text-pane borders instead of a separate info row: split panes show their own Before/After revision labels at the bottom-left; a single visible pane shows the combined `before → after` label there. Each pane shows its read-only/editable state at the top-right, independently of keyboard focus. Before is always read-only; After is editable only for an eligible Working-checkout or checkout-matched pinned file. The changed-file explorer and panes start directly below configured chrome, and there is no native-buffer help footer. Loading/error/file notices remain visible when applicable. Narrow borders prioritize state and clip titles without painting over the gutter or source text. Diff metadata lives in the text-pane borders instead of a separate info row: split panes show their own Before/After revision labels at the bottom-left; a single visible pane shows the combined `before → after` label there. Each pane shows its read-only/editable state at the top-right, independently of keyboard focus. Before is always read-only; After is editable only for an eligible Working-checkout or checkout-matched pinned file. The changed-file explorer and panes start directly below configured chrome, and there is no native-buffer help footer. Loading/error/file notices remain visible when applicable. Narrow borders prioritize state and clip titles without painting over the gutter or source text.
Default diff tab titles show `Diff · +N -M · filename`, with added and removed line counts for the selected file, not the whole comparison. Replaced lines count on both sides; unchanged context does not count. Counts update with live edits and undo/redo, including when the diff tab is inactive. Revision selectors and files still loading omit counts until their text diff is ready. Custom chrome templates include this prefix through `{view}`. Default diff tab titles show `Diff · filename +N -M`, with added and removed line counts for the selected file. Replaced lines count on both sides; unchanged context does not count. Counts update with live edits and undo/redo, including when the diff tab is inactive. Revision selectors and files still loading omit per-file counts until their text diff is ready. Custom chrome templates use `{view}{name}{changes}` for this layout.
The default top-right `:diff` segment shows `Diff +N -M` across the entire active comparison, including unopened and filtered-out files. Unopened files use native Git text statistics from frozen snapshot blobs; loaded files use their displayed hunks, including live edits. Binary and mode-only changes contribute no text lines. Git's binary detection and diff algorithm can differ from the renderer for non-UTF-8 content, late NUL bytes, or difficult comparisons, so opening such a file can adjust totals. Oversized blobs, unmaterialized jj conflict projections, or failed statistics collection leave totals as `Diff …` until all counts are known; partial sums are never shown as complete totals.
For jj-managed repositories, revision entries and border labels display only the change ID; Git repositories keep commit-ID labels, and named bookmarks retain their names. The backing commit ID remains the exact comparison target and can still be used to filter revisions. For jj-managed repositories, revision entries and border labels display only the change ID; Git repositories keep commit-ID labels, and named bookmarks retain their names. The backing commit ID remains the exact comparison target and can still be used to filter revisions.
@@ -452,38 +487,57 @@ 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 by immutable per-file Before/After identities and file paths. Changes to unrelated files or commit metadata do not clear them; changes to either side's bytes, final newline, file mode, or rename paths require re-review. They survive filtering, file navigation, lazy loading, tabs, split/unified/orientation changes, and pinned checkout-only transitions. Editing a writable After file clears only that file's mark and saves the removal; unchanged refreshes and saves preserve it. Marks on live text also retain its exact contents, including the final newline, so different unsaved text cannot inherit a checkmark. Marks do not change document text, undo history, or repository state. Without configured SQLite storage, use `:review.save path` to save them as JSON. Older revision-pair-scoped marks remain stored but must be marked again to acquire per-file identities; comments, summaries, and verdicts are unaffected.
**Local review comments** 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** work in ordinary repository source buffers and diff text panes; they share storage with file-reviewed checkmarks and never publish to GitHub. Run **`:comment`** to comment on the current line. A characterwise Visual selection (`v`) anchors its exact text, including partial lines and multiline spans; Visual Line (`V`) anchors whole selected lines. Multiple independent carets/ranges must be reviewed separately.
Comments, summaries, and the review overview open in a focused floating editor over the whole diff text workspace, not a source-side split. The source layout, file explorer, and viewports stay unchanged. Normal editing, selections, and undo/redo work in editable review buffers. Escape in Normal mode or `:pane.close` dismisses the window and returns to the diff; drafts remain available for reopening and saving. Insert/Visual/Command Escape first retains its usual modal behavior. Source rows are never replaced with comment text. Magenta **`○`** gutter markers identify comment ranges; **`●`** identifies the selected comment. Its exact current text range has a magenta background, including wrapped continuations and selected newline cells; native Visual selections take precedence. Stale comments have neither markers nor text highlighting. Markers preserve addition/deletion signs, appear only on the first wrapped segment, and also work in unified view. Comments, summaries, and the review overview open in a focused floating editor over the source workspace, not a source-side split. The source layout, file explorer, and viewports stay unchanged. Normal editing, selections, and undo/redo work in editable review buffers. Escape in Normal mode or `:pane.close` dismisses the window and returns to the source, discarding that editor's unsaved text. An unsaved new comment is not attached to the review; an existing comment or summary reopens with its last saved text and no discarded undo history. Insert/Visual/Command Escape first retains its usual modal behavior. Source rows are never replaced with comment text. Magenta **`○`** gutter markers identify saved comment ranges; **`●`** identifies the selected comment. Its exact current text range has a magenta background, including wrapped continuations and selected newline cells; native Visual selections take precedence. Stale comments have neither markers nor text highlighting. Markers preserve addition/deletion signs, appear only on the first wrapped segment, and also work in unified view.
Local review buffers (comments, summaries, overview, and stored-review selector) soft-wrap by default without inserting newlines into saved text. Use `:word_wrap.toggle` to disable wrapping for the focused review buffer; reopening it retains that choice for the session. Source diff wrapping remains independent.
| Command | Effect | | Command | Effect |
| --- | --- | | --- | --- |
| `:diff.comment` | Add a comment on the current line or exact selected text range, on the focused side | | `:comment` | Add a comment on the current source line or exact selected text range |
| `:diff.review.edit [id]` | Edit an existing comment; defaults to the overview entry under the caret or selected comment | | `:review.edit [id]` | Edit an existing comment; defaults to the overview entry under the caret or selected comment |
| `:diff.review.delete [id]` | Delete that draft comment | | `:review.delete [id]` | Delete that draft comment |
| `:diff.review.resolve [id]` / `:diff.review.unresolve [id]` | Mark a comment resolved/unresolved; uses the same default selection as edit/delete | | `:review.resolve [id]` / `:review.unresolve [id]` | Mark a comment resolved/unresolved; uses the same default selection as edit/delete |
| `:diff.review.next` / `:diff.review.previous` | Cycle comments, opening their text and revealing a matching source anchor | | `:review.next` / `:review.previous` | Cycle comments, opening their text and revealing a matching source anchor |
| `:diff.review.overview` | Refresh/open a read-only summary and comment list; Enter edits the entry under the caret | | `:review.overview` | Refresh/open a read-only summary and comment list; Enter edits the entry under the caret |
| `:diff.review.summary` | Compose the review summary | | `:review.copy` | Copy all saved comments in the selected review to the configured clipboard, using the CLI text format |
| `:diff.review.verdict looks_good` | Record **Looks good** | | `:review.summary` | Compose the review summary |
| `:diff.review.verdict changes_requested` | Record **Changes requested**; `none` clears the verdict | | `:review.verdict looks_good` | Record **Looks good** |
| `:diff.review.save [path]` | Commit to SQLite; with a path, also export JSON for sharing | | `:review.verdict changes_requested` | Record **Changes requested**; `none` clears the verdict |
| `:diff.review.open [id or path]` | Show stored reviews, open a stored review ID, or import a JSON review as a new review | | `:review.save [path]` | Commit to SQLite; with a path, also export JSON for sharing |
| `:diff.review.new` | Create a separate stored review for the current repository | | `: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 another stored review for the current comparison or ordinary-source scope |
Reviews save automatically to a local SQLite database when you dismiss or switch comment/summary editors. Verdicts, deletions, and manual resolution changes commit immediately; typing alone does not write per keystroke. **`:w`** explicitly commits the entire review, including hidden drafts. `:diff.review.save path` or `:w path` additionally exports JSON; later autosaves still go to SQLite and leave that export unchanged. Imports create a new stored review identity rather than overwriting another review. File checkmarks remain diff-specific (`:diff.review.toggle`).
Storage defaults to `$XDG_DATA_HOME/vamp/reviews/reviews.sqlite3`, or `~/.local/share/vamp/reviews/reviews.sqlite3` when XDG_DATA_HOME is unset or relative. Configure `review_directory` to override its directory; relative configuration paths resolve against the config file. Repository identity is the canonical checkout root. Each review has a persistent database ID independent of comparison revisions, so reopening changed revisions retains stale comments. Opening a comparison automatically restores the repository's sole review. Multiple reviews open a floating selector: move to an entry and press Enter, or use `:diff.review.open id`. Use `:diff.review.new` for another independent review. Comment and summary text is committed only by **`:w`** or **`:review.save`**, which saves the entire review, including hidden drafts. Dismissing an editor discards its unsaved text; switching between review editors can retain hidden drafts but does not save them. File checkmarks, verdicts, deletions, and manual resolution changes commit immediately to local SQLite without committing draft text. `:review.save path` or `:w path` additionally exports JSON; later metadata autosaves still go to SQLite and leave that export unchanged. Imports create a new stored review identity rather than overwriting another review.
Write errors, locked databases, incompatible schemas, and conflicting edits from another editor instance retain dirty drafts and report the failure. Normal destructive closes remain guarded until persistence succeeds. `: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. `:review.copy` copies the same text as `vamp --review show ID --all`: repository, summary, verdict, counts, and every saved comment with its anchor and exact excerpt. Resolved and stale comments are included, across all files in the selected review. `clipboard_mode` chooses the system clipboard or Separate-mode internal register. Copying does not save or include unsaved comment/summary drafts. No comments or a clipboard write failure reports an error without replacing the register.
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 formats, including version 2, are rejected and preserved without migration. Working comparisons use content-addressed snapshot IDs; captured text additionally freezes live unsaved edits. Each stored or exported review is limited to 64 MiB. CLI text output and `:review.copy` wrap each source excerpt in a Markdown code block, with its detected language tag (`hon` for `.hon`, `rust` for `.rs`, and so on). Detection uses the frozen source path and first-line shebang, not current disk contents; unknown languages use an untagged block. Fences grow beyond any backtick run in the excerpt, so nested Markdown cannot end the block early. Feedback follows outside the code block. Exact excerpt text, including existing final newlines, is preserved; a missing final newline gains only the separator required before the closing fence. JSON excerpts and stored anchors remain raw and unchanged.
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. 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.
Resolution is a manual human decision, independent of anchor freshness: a comment can be both resolved and stale. Overview entries and comment titles label resolved/unresolved status. Resolved comments remain in the overview and next/previous navigation, and can still be edited or reopened. Resolving never changes an anchor or deletes feedback. 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. 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.
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 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.
Ordinary source comments use an immutable content snapshot identity and capture the current buffer, including unsaved text; they do not require a commit or a diff. They retain the same SQLite/JSON formats. Opening a source comment navigates to its file and mapped selection; if that text is stale or the file is unavailable, it opens the preserved original text read-only instead of attaching feedback to different code.
Resolution is an explicit reviewer or agent decision, independent of anchor freshness: a comment can be both resolved and stale. Overview entries and comment titles label resolved/unresolved status. Resolved comments remain in the overview and next/previous navigation, and can still be edited or reopened. Resolving never changes an anchor or deletes feedback.
Text panes show change excerpts with **10 unchanged lines above and below** each change, preserving original file line numbers. Overlapping or touching excerpts merge; omitted stretches show an unchanged-line count in both panes. Files with only metadata changes show their notice or “No text changes” rather than unrelated source. Text panes show change excerpts with **10 unchanged lines above and below** each change, preserving original file line numbers. Overlapping or touching excerpts merge; omitted stretches show an unchanged-line count in both panes. Files with only metadata changes show their notice or “No text changes” rather than unrelated source.
@@ -527,6 +581,8 @@ Run **`:resolve`** in Normal mode for the current jj working copy. It opens a de
The resolver shows the editable **Resolution** on the left and a read-only **Other version** on the right, with aligned rows, line/character highlighting, context folds and synchronized scrolling. Both are projected from the current working buffer, including pre-existing unsaved edits: only conflict-marker regions are replaced by their selected alternatives. Already-merged text and edits outside those regions appear on both sides. The initial output is seeded from the first side as a normal undoable buffer change; opening never writes it to disk. References remain frozen while later edits change only the output. The resolver shows the editable **Resolution** on the left and a read-only **Other version** on the right, with aligned rows, line/character highlighting, context folds and synchronized scrolling. Both are projected from the current working buffer, including pre-existing unsaved edits: only conflict-marker regions are replaced by their selected alternatives. Already-merged text and edits outside those regions appear on both sides. The initial output is seeded from the first side as a normal undoable buffer change; opening never writes it to disk. References remain frozen while later edits change only the output.
Both panes show their own changes **against the selected base**, not a left-to-right removal/addition comparison: removed base lines are red with `-`, and added candidate lines are green with `+`, on either side. Replacements show the removed base text followed by the candidate text; unchanged text stays neutral. Removed base lines are display-only, with no candidate line number, caret, or editable selection. With no base (an add/add conflict), both candidates are additions against empty text. The Resolution baseline uses the same frozen working-text projection as its alternatives, preserving already-merged text and pre-existing outside edits. `resolve.base` selects the baseline through History; returning to Resolution uses that selection. Hunk navigation and `resolve.use_source` still operate on differences between the actual Draft and Alternative.
Pane borders label the editable output **Resolution** and its frozen reference **Other version**. Paths and branch-comparison metadata are omitted from these titles; unsaved state belongs only to the output, and source identity appears with the reference. Explorer **C** marks files jj still considers conflicted, including while edited/checking or confirmation is unavailable; it clears only when jj confirms resolution. Pane borders label the editable output **Resolution** and its frozen reference **Other version**. Paths and branch-comparison metadata are omitted from these titles; unsaved state belongs only to the output, and source identity appears with the reference. Explorer **C** marks files jj still considers conflicted, including while edited/checking or confirmation is unavailable; it clears only when jj confirms resolution.
| Command | Behavior | | Command | Behavior |
@@ -542,6 +598,8 @@ Pane borders label the editable output **Resolution** and its frozen reference *
**History** is separate from editing: it compares the original inputs and their base, with all sides and bases selectable for multi-sided conflicts. `config.resolve_history_layout = "columns"` (default) shows left/base/right; `"base_below"` shows left/right above a full-width base. Both layouts retain aligned changes and synchronized vertical scrolling. Tab/Shift+Tab cycles in layout order; directional pane actions follow the displayed geometry. Narrow History shows the focused source, as does base-below History when too short to display both rows of panes. Draft text, undo, caret and viewport survive view switches. Resolution uses the ordinary diff workspace, including its configured explorer, split/unified/orientation controls, source-line navigation, excerpts, focus and scrolling. Read-only Alternatives and History reject edits and saves. **History** is separate from editing: it compares the original inputs and their base, with all sides and bases selectable for multi-sided conflicts. `config.resolve_history_layout = "columns"` (default) shows left/base/right; `"base_below"` shows left/right above a full-width base. Both layouts retain aligned changes and synchronized vertical scrolling. Tab/Shift+Tab cycles in layout order; directional pane actions follow the displayed geometry. Narrow History shows the focused source, as does base-below History when too short to display both rows of panes. Draft text, undo, caret and viewport survive view switches. Resolution uses the ordinary diff workspace, including its configured explorer, split/unified/orientation controls, source-line navigation, excerpts, focus and scrolling. Read-only Alternatives and History reject edits and saves.
Each outer History pane uses the same red-removal/green-addition presentation against the selected base. The base pane highlights original text changed by either input; addition-only rows leave a gap there. This applies to both History layouts.
Input indexes are one-based and include bases. Conflict-specific actions appear in the palette and through corresponding `config.core.resolve` methods. File navigation uses the existing picker/explorer actions: Space o opens floating search, Space e focuses the dock, Ctrl+t switches Tree/List, Ctrl+w closes it, and configured tree/selection bindings apply unchanged. Enter selects a relevant conflict file and focuses the editable Draft. Normal-mode Tab/Shift+Tab, native motions/search, context expansion and layout actions follow normal diff behavior. The complete seeded Draft can be saved even when intentional differences remain against the Alternative: those differences are not unresolved conflict markers. There is no automatic “accept both”. Input indexes are one-based and include bases. Conflict-specific actions appear in the palette and through corresponding `config.core.resolve` methods. File navigation uses the existing picker/explorer actions: Space o opens floating search, Space e focuses the dock, Ctrl+t switches Tree/List, Ctrl+w closes it, and configured tree/selection bindings apply unchanged. Enter selects a relevant conflict file and focuses the editable Draft. Normal-mode Tab/Shift+Tab, native motions/search, context expansion and layout actions follow normal diff behavior. The complete seeded Draft can be saved even when intentional differences remain against the Alternative: those differences are not unresolved conflict markers. There is no automatic “accept both”.
**`:w` saves and checks jj**, asynchronously. Before writing, the resolver locks the working copy and validates the frozen checkout and exact on-disk file state. Checkout changes, external writes and unsafe filesystem ancestors refuse the write, including force-save. Successful writes trigger jj's normal snapshot operation, which may capture other on-disk changes. The list distinguishes unresolved, unsaved, checking, resolved and unconfirmed states. jj—not marker scanning or a manual checkbox—confirms resolution. A failed confirmation keeps saved contents and reports unconfirmed status. Resolved files remain in the session for review. Dirty drafts prevent destructive resolver/pane/tab closes. **`:w` saves and checks jj**, asynchronously. Before writing, the resolver locks the working copy and validates the frozen checkout and exact on-disk file state. Checkout changes, external writes and unsafe filesystem ancestors refuse the write, including force-save. Successful writes trigger jj's normal snapshot operation, which may capture other on-disk changes. The list distinguishes unresolved, unsaved, checking, resolved and unconfirmed states. jj—not marker scanning or a manual checkbox—confirms resolution. A failed confirmation keeps saved contents and reports unconfirmed status. Resolved files remain in the session for review. Dirty drafts prevent destructive resolver/pane/tab closes.
@@ -592,7 +650,7 @@ config.bind "leader f", config.core.follow_link
## Syntax highlighting ## Syntax highlighting
Buffers highlight automatically using [Tree-sitter](https://tree-sitter.github.io/tree-sitter/) and compiled-in grammar/query packages. The selected language set is Rust, Ruby, Python, JSON, Bash, Makefile, Markdown, JavaScript/JSX, TypeScript/TSX, HTML, CSS, YAML, TOML, C, C++, Go, Zig (`.zig`), and Honey (`.hon`). Detection uses filename/extension or the in-memory first-line shebang, including common special filenames such as Makefile, Gemfile, and Cargo.lock; no file reread occurs. Unsupported formats use the normal foreground. The detected language appears in status, and save-as redetects it. Buffers highlight automatically using [Tree-sitter](https://tree-sitter.github.io/tree-sitter/) and compiled-in grammar/query packages. The selected language set is Rust, Ruby, Python, JSON, Bash, Makefile, Markdown, JavaScript/JSX, TypeScript/TSX, HTML, CSS, YAML, TOML, C, C++, Go, Zig (`.zig`), Honey (`.hon`), and Typst (`.typ`). Detection uses filename/extension or the in-memory first-line shebang, including common special filenames such as Makefile, Gemfile, and Cargo.lock; no file reread occurs. Unsupported formats use the normal foreground. The detected language appears in status, and save-as redetects it.
Files remain editable above the syntax budget: sources over 1 MiB, or with any line over 64 KiB, use plain text and show the reason in the language status. Syntax highlighting resumes automatically after shrinking below both limits. This avoids building a full parse tree for huge generated files; navigation, search, selections, undo/redo, and saving remain available. Files remain editable above the syntax budget: sources over 1 MiB, or with any line over 64 KiB, use plain text and show the reason in the language status. Syntax highlighting resumes automatically after shrinking below both limits. This avoids building a full parse tree for huge generated files; navigation, search, selections, undo/redo, and saving remain available.
@@ -600,6 +658,8 @@ Colors retain the terminal palette: comments dark gray, strings green, keywords
Honey uses its bundled grammar and highlight queries, including declarations, primitive types, literals, and comments. Markdown fences accept `honey`, `honeylang`, or `hon`; the language ID is `honey`. Honey uses its bundled grammar and highlight queries, including declarations, primitive types, literals, and comments. Markdown fences accept `honey`, `honeylang`, or `hon`; the language ID is `honey`.
Typst highlights headings, emphasis, references, raw code, comments, strings, numbers, keywords, and function calls without an LSP. Markdown fences accept `typst` or `typ`; named raw-code blocks inside Typst use the same bundled language registry. The language ID is `typst`. Native [Tinymist configuration](configuration.md#native-lsp) is opt-in.
Markdown heading markers (`#` through `######`, or Setext underlines) share the heading text's markup color. List markers (`-`, `+`, `*`, and ordered prefixes such as `1.` or `1)`) also use cyan markup, including nested and blockquoted lists. Plain-text hashes and other punctuation keep their existing styling. Markdown heading markers (`#` through `######`, or Setext underlines) share the heading text's markup color. List markers (`-`, `+`, `*`, and ordered prefixes such as `1.` or `1)`) also use cyan markup, including nested and blockquoted lists. Plain-text hashes and other punctuation keep their existing styling.
An enabled supporting LSP server supplies semantic tokens automatically, overriding syntax colors only on recognized spans. Semantic colors also use the terminal palette: namespaces neutral gray, types cyan, variables/parameters light cyan, and properties blue. Readonly variables/parameters/properties keep those role colors and add italics; declarations/definitions are bold, documentation comments italic, and deprecated symbols struck through. Italic emphasis depends on terminal support. Edits retain semantic colors outside the changed range and shift them with the text until updated tokens arrive, avoiding whole-buffer flashes. Tree-sitter fills gaps, including edited ranges whose semantic tokens were discarded. Search, Visual selections, diagnostics, and carets keep their existing precedence. See [native LSP configuration](configuration.md#native-lsp) for provider support and server-stack routing. An enabled supporting LSP server supplies semantic tokens automatically, overriding syntax colors only on recognized spans. Semantic colors also use the terminal palette: namespaces neutral gray, types cyan, variables/parameters light cyan, and properties blue. Readonly variables/parameters/properties keep those role colors and add italics; declarations/definitions are bold, documentation comments italic, and deprecated symbols struck through. Italic emphasis depends on terminal support. Edits retain semantic colors outside the changed range and shift them with the text until updated tokens arrive, avoiding whole-buffer flashes. Tree-sitter fills gaps, including edited ranges whose semantic tokens were discarded. Search, Visual selections, diagnostics, and carets keep their existing precedence. See [native LSP configuration](configuration.md#native-lsp) for provider support and server-stack routing.
+8 -3
View File
@@ -5,6 +5,8 @@ Vamp.configure do |config|
config.leader = "space" config.leader = "space"
config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal
config.git_blame = true # false disables current-line inline blame config.git_blame = true # false disables current-line inline blame
config.cursor = {} # mode colors enabled; block cursor in every mode
# config.cursor = { mode_colors: false, insert: :bar, visual: :underline }
config.scroll_distance = 0 # nonnegative edge distance, or nil to center config.scroll_distance = 0 # nonnegative edge distance, or nil to center
config.scroll_step_lines = nil # Ctrl+u/d: half viewport, or a positive screen-row count config.scroll_step_lines = nil # Ctrl+u/d: half viewport, or a positive screen-row count
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
@@ -18,10 +20,12 @@ Vamp.configure do |config|
config.diff_view_mode = "split" # or "unified" config.diff_view_mode = "split" # or "unified"
config.diff_split_orientation = "vertical" # or "horizontal" config.diff_split_orientation = "vertical" # or "horizontal"
config.diff_follow_working_copy = true # false keeps the selected Working After endpoint config.diff_follow_working_copy = true # false keeps the selected Working After endpoint
config.pull_request_review_requested_symbol = "🙏"
config.pull_request_approved_symbol = "👍"
config.resolve_history_layout = "columns" # or "base_below" for left/right above the base config.resolve_history_layout = "columns" # or "base_below" for left/right above the base
# config.popup.command = { anchor: :bottom_left } # config.popup.command = { anchor: :bottom_left }
# config.popup.search = { anchor: :center } # config.popup.search = { anchor: :center }
# Status above, tabs and live battery below; either bar accepts all segments. # Status above, tabs, live battery and pending shell command below.
config.bar.top = { config.bar.top = {
left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer], left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer],
center: [], right: [:language, :lsp, :position], center: [], right: [:language, :lsp, :position],
@@ -29,9 +33,9 @@ Vamp.configure do |config|
position: { padding: 1, leading_edge: "░▓", trailing_edge: "" } position: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
} }
config.bar.bottom = { config.bar.bottom = {
left: [:tabs], center: [], right: [:battery], left: [:tabs], center: [], right: [:diff, :shell, :battery],
tabs: { tabs: {
title: "{index} {view}{name}{modified}", padding: 1, title: "{index} {view}{name}{changes}{modified}", padding: 1,
leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: "" leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: ""
}, },
battery: { padding: 1, leading_edge: "░▓", trailing_edge: "" } battery: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
@@ -43,6 +47,7 @@ Vamp.configure do |config|
# config.lsp["rust"] = true # built-in rust-analyzer preset # config.lsp["rust"] = true # built-in rust-analyzer preset
# config.lsp["ruby"] = true # built-in Solargraph stdio preset (gem install solargraph) # config.lsp["ruby"] = true # built-in Solargraph stdio preset (gem install solargraph)
# config.lsp["python"] = true # built-in ty + Ruff stack (install both separately) # config.lsp["python"] = true # built-in ty + Ruff stack (install both separately)
# config.lsp["typst"] = true # built-in Tinymist LSP preset (install tinymist separately)
# config.lsp["my-language"] = { # config.lsp["my-language"] = {
# command: ["my-language-server", "--stdio"], extensions: ["myl"], # command: ["my-language-server", "--stdio"], extensions: ["myl"],
# initialization_options: {}, settings: {} # initialization_options: {}, settings: {}
+118 -53
View File
@@ -17,7 +17,7 @@ pub(crate) const LIMIT: usize = 16 * 1024 * 1024;
const TIMEOUT: Duration = Duration::from_secs(5); const TIMEOUT: Duration = Duration::from_secs(5);
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
type ResultFile = Result<Option<Vec<Option<Arc<LineBlame>>>>, String>; type ResultFile = Result<Option<Vec<Option<Arc<LineBlame>>>>, String>;
type Request = (u64, u64, PathBuf, String); type Request = (u64, u64, PathBuf, String, Option<RevisionSource>);
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub(crate) struct LineBlame { pub(crate) struct LineBlame {
@@ -27,6 +27,14 @@ pub(crate) struct LineBlame {
pub(crate) uncommitted: bool, pub(crate) uncommitted: bool,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct RevisionSource {
pub(crate) store: PathBuf,
pub(crate) commit: String,
pub(crate) path: PathBuf,
pub(crate) keepalive: Option<Arc<crate::repository::Temporary>>,
}
#[derive(Default)] #[derive(Default)]
struct State { struct State {
request: Option<Request>, request: Option<Request>,
@@ -63,13 +71,16 @@ impl BlameWorker {
if worker.stopped.load(Ordering::Acquire) { if worker.stopped.load(Ordering::Acquire) {
break; break;
} }
let (generation, id, path, contents) = state.request.take().unwrap(); let (generation, id, path, contents, source) = state.request.take().unwrap();
drop(state); drop(state);
let cancelled = || { let cancelled = || {
worker.stopped.load(Ordering::Acquire) worker.stopped.load(Ordering::Acquire)
|| worker.generation.load(Ordering::Acquire) != generation || worker.generation.load(Ordering::Acquire) != generation
}; };
let result = read_file(&path, &contents, &cancelled); let result = match source {
Some(source) => read_revision(&source, &contents, &cancelled),
None => read_file(&path, &contents, &cancelled),
};
let Ok(mut state) = worker.state.lock() else { let Ok(mut state) = worker.state.lock() else {
return; return;
}; };
@@ -84,7 +95,13 @@ impl BlameWorker {
}) })
} }
pub(crate) fn request(&mut self, id: u64, path: PathBuf, contents: String) -> io::Result<()> { pub(crate) fn request(
&mut self,
id: u64,
path: PathBuf,
contents: String,
source: Option<RevisionSource>,
) -> io::Result<()> {
if contents.len() > LIMIT { if contents.len() > LIMIT {
return Err(io::Error::new( return Err(io::Error::new(
io::ErrorKind::InvalidInput, io::ErrorKind::InvalidInput,
@@ -101,7 +118,7 @@ impl BlameWorker {
.generation .generation
.fetch_add(1, Ordering::AcqRel) .fetch_add(1, Ordering::AcqRel)
.wrapping_add(1); .wrapping_add(1);
state.request = Some((generation, id, path, contents)); state.request = Some((generation, id, path, contents, source));
state.result = None; state.result = None;
self.shared.wake.notify_one(); self.shared.wake.notify_one();
Ok(()) Ok(())
@@ -279,19 +296,93 @@ pub(crate) fn git_without_literal_pathspecs(root: &Path) -> Command {
fn oid(value: &str) -> bool { fn oid(value: &str) -> bool {
matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit()) matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
} }
fn path_bytes(bytes: &[u8]) -> Result<PathBuf, String> { fn read_revision(
#[cfg(unix)] source: &RevisionSource,
contents: &str,
cancelled: &dyn Fn() -> bool,
) -> ResultFile {
if contents.len() > LIMIT {
return Err("Blame input exceeds 16 MiB".into());
}
if !oid(&source.commit) {
return Err("Invalid blame commit".into());
}
crate::repository::safe_path(&source.path)?;
let rows = contents.split_inclusive('\n').count();
if rows == 0 || source.commit.bytes().all(|byte| byte == b'0') {
return Ok(None);
}
let mut objects = git(&source.store);
objects.arg("--git-dir").arg(&source.store).args([
"rev-parse",
"--path-format=absolute",
"--git-path",
"objects",
]);
let objects = run(objects, &[], cancelled)?.ok_or("Cannot resolve blame object store")?;
let private = Temporary::new()?;
fs::create_dir(private.0.join("refs"))
.and_then(|()| fs::create_dir_all(private.0.join("objects/info")))
.map_err(|_| "Cannot prepare private blame repository")?;
// Keep relative alternates anchored in their original object directory.
let mut alternate = vec![b'"'];
for &byte in objects.strip_suffix(b"\n").unwrap_or(&objects) {
if (b' '..=b'~').contains(&byte) && !matches!(byte, b'"' | b'\\') {
alternate.push(byte);
} else {
write!(&mut alternate, "\\{byte:03o}").unwrap();
}
}
alternate.extend_from_slice(b"\"\n");
fs::write(private.0.join("objects/info/alternates"), alternate)
.and_then(|()| fs::write(private.0.join("HEAD"), format!("{}\n", source.commit)))
.map_err(|_| "Cannot prepare blame parent")?;
if source.commit.len() == 64 {
fs::write(
private.0.join("config"),
"[core]\nrepositoryformatversion = 1\n[extensions]\nobjectFormat = sha256\n",
)
.map_err(|_| "Cannot prepare blame object format")?;
}
let command = || {
let mut command = git(&private.0);
command
.arg("--git-dir")
.arg(&private.0)
.arg("--work-tree")
.arg(&private.0);
command
};
let mut present = command();
present
.args(["ls-tree", "-r", "-z", "--full-tree", "HEAD", "--"])
.arg(&source.path);
let Some(entry) = run(present, &[], cancelled)? else {
return Ok(None);
};
let Some(tab) = entry.iter().position(|&byte| byte == b'\t') else {
return Ok(None);
};
if (!entry.starts_with(b"100644 blob ") && !entry.starts_with(b"100755 blob "))
|| entry[tab + 1..].strip_suffix(b"\0") != Some(source.path.as_os_str().as_encoded_bytes())
{ {
use std::{ffi::OsString, os::unix::ffi::OsStringExt}; return Ok(None);
Ok(PathBuf::from(OsString::from_vec(bytes.to_vec())))
}
#[cfg(not(unix))]
{
Ok(PathBuf::from(
std::str::from_utf8(bytes).map_err(|_| "Invalid repository path")?,
))
} }
let mut blame = command();
blame
.args([
"blame",
"--incremental",
"--no-textconv",
"--contents",
"-",
"--",
])
.arg(&source.path);
let output = run(blame, contents.as_bytes(), cancelled)?.ok_or("Cannot read Git blame")?;
parse(&output, rows).map(Some)
} }
fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> ResultFile { fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> ResultFile {
if contents.len() > LIMIT { if contents.len() > LIMIT {
return Err("Blame input exceeds 16 MiB".into()); return Err("Blame input exceeds 16 MiB".into());
@@ -320,7 +411,6 @@ fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> Resul
}) { }) {
return Ok(None); return Ok(None);
} }
let mut temporary = None;
let mut command = git(root); let mut command = git(root);
if root.join(".jj").exists() { if root.join(".jj").exists() {
let mut jj = Command::new("jj"); let mut jj = Command::new("jj");
@@ -352,44 +442,19 @@ fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> Resul
if !oid(parent) { if !oid(parent) {
return Err("Invalid Jujutsu blame metadata".into()); return Err("Invalid Jujutsu blame metadata".into());
} }
let mut repo = root.join(".jj/repo"); let store = crate::repository::store_for_jj(root)?;
if repo.is_file() { return read_revision(
let pointer = fs::read(&repo).map_err(|_| "Cannot read Jujutsu repository pointer")?; &RevisionSource {
repo = root store,
.join(".jj") commit: parent.into(),
.join(path_bytes(pointer.strip_suffix(b"\n").unwrap_or(&pointer))?); path: relative.to_path_buf(),
keepalive: None,
},
contents,
cancelled,
);
} }
let store = repo.join("store"); let mut present = git(root);
let target = fs::read(store.join("git_target"))
.map_err(|_| "Only Git-backed Jujutsu blame is supported")?;
let store = fs::canonicalize(
store.join(path_bytes(target.strip_suffix(b"\n").unwrap_or(&target))?),
)
.map_err(|_| "Cannot resolve Jujutsu Git store")?;
let private = Temporary::new()?;
fs::create_dir(private.0.join("refs"))
.map_err(|_| "Cannot prepare private blame repository")?;
#[cfg(unix)]
std::os::unix::fs::symlink(store.join("objects"), private.0.join("objects"))
.map_err(|_| "Cannot link blame objects")?;
#[cfg(not(unix))]
return Err("Jujutsu blame requires Unix object links".into());
fs::write(private.0.join("HEAD"), format!("{parent}\n"))
.map_err(|_| "Cannot prepare blame parent")?;
command
.arg("--git-dir")
.arg(&private.0)
.arg("--work-tree")
.arg(root);
temporary = Some(private);
}
let mut present = if let Some(private) = &temporary {
let mut command = git(root);
command.arg("--git-dir").arg(&private.0);
command
} else {
git(root)
};
let mut object = std::ffi::OsString::from("HEAD:"); let mut object = std::ffi::OsString::from("HEAD:");
object.push(relative); object.push(relative);
present.args(["cat-file", "-e"]).arg(object); present.args(["cat-file", "-e"]).arg(object);
+19 -27
View File
@@ -102,7 +102,7 @@ pub struct ChromeLine {
pub segments: BTreeMap<String, SegmentConfig>, pub segments: BTreeMap<String, SegmentConfig>,
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SegmentConfig { pub struct SegmentConfig {
pub title: String, pub title: String,
pub padding: usize, pub padding: usize,
@@ -112,28 +112,17 @@ pub struct SegmentConfig {
pub first_leading_edge: Option<String>, pub first_leading_edge: Option<String>,
} }
impl Default for SegmentConfig {
fn default() -> Self {
Self {
title: String::new(),
padding: 1,
margin: [0; 2],
leading_edge: String::new(),
trailing_edge: String::new(),
first_leading_edge: None,
}
}
}
impl ChromeLine { impl ChromeLine {
pub fn top() -> Self { pub fn top() -> Self {
Self { Self {
left: vec!["tabs".into()], left: vec!["tabs".into()],
center: Vec::new(), center: Vec::new(),
right: vec!["battery".into()], right: vec!["diff".into(), "shell".into(), "battery".into()],
segments: BTreeMap::from([ segments: BTreeMap::from([
("tabs".into(), default_segment("tabs")), ("tabs".into(), default_segment("tabs")),
("diff".into(), default_segment("diff")),
("battery".into(), default_segment("battery")), ("battery".into(), default_segment("battery")),
("shell".into(), default_segment("shell")),
]), ]),
} }
} }
@@ -148,13 +137,7 @@ impl ChromeLine {
right: vec!["language".into(), "lsp".into(), "position".into()], right: vec!["language".into(), "lsp".into(), "position".into()],
segments: DEFAULT_BOTTOM_SEGMENTS segments: DEFAULT_BOTTOM_SEGMENTS
.iter() .iter()
.map(|name| { .map(|name| ((*name).into(), default_segment(name)))
let mut segment = default_segment(name);
if *name == "position" {
segment.margin[0] = 1;
}
((*name).into(), segment)
})
.collect(), .collect(),
} }
} }
@@ -212,14 +195,20 @@ impl ChromeLine {
fn default_segment(name: &str) -> SegmentConfig { fn default_segment(name: &str) -> SegmentConfig {
let mut segment = SegmentConfig::default(); let mut segment = SegmentConfig::default();
if matches!(name, "tabs" | "position" | "battery") { if matches!(name, "tabs" | "position" | "battery" | "shell") {
segment.leading_edge = "░▓".into(); segment.leading_edge = "░▓".into();
} }
if matches!(name, "tabs" | "mode") { if matches!(name, "tabs" | "mode" | "shell") {
segment.trailing_edge = "▓░".into(); segment.trailing_edge = "▓░".into();
} }
if matches!(
name,
"tabs" | "mode" | "position" | "battery" | "shell" | "lsp"
) {
segment.padding = 1;
}
if name == "tabs" { if name == "tabs" {
segment.title = "{index} {view}{name}{modified}".into(); segment.title = "{index} {view}{name}{changes}{modified}".into();
segment.first_leading_edge = Some(String::new()); segment.first_leading_edge = Some(String::new());
} }
segment segment
@@ -248,9 +237,12 @@ fn validate_title(title: &str) -> Result<(), String> {
} }
let end = rest.find('}').ok_or("title contains an unmatched '{'")?; let end = rest.find('}').ok_or("title contains an unmatched '{'")?;
let placeholder = &rest[1..end]; let placeholder = &rest[1..end];
if !matches!(placeholder, "index" | "view" | "name" | "path" | "modified") { if !matches!(
placeholder,
"index" | "view" | "name" | "path" | "changes" | "modified"
) {
return Err(format!( return Err(format!(
"unknown title placeholder {{{placeholder}}}; expected index, view, name, path, or modified" "unknown title placeholder {{{placeholder}}}; expected index, view, name, path, changes, or modified"
)); ));
} }
rest = &rest[end + 1..]; rest = &rest[end + 1..];
+46
View File
@@ -0,0 +1,46 @@
use crate::editor::Mode;
use serde::Deserialize;
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct CursorConfig {
pub(crate) mode_colors: bool,
pub(crate) normal: CursorShape,
pub(crate) insert: CursorShape,
pub(crate) command: CursorShape,
pub(crate) search: CursorShape,
pub(crate) visual: CursorShape,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CursorShape {
Block,
Bar,
Underline,
}
impl Default for CursorConfig {
fn default() -> Self {
Self {
mode_colors: true,
normal: CursorShape::Block,
insert: CursorShape::Block,
command: CursorShape::Block,
search: CursorShape::Block,
visual: CursorShape::Block,
}
}
}
impl CursorConfig {
pub(crate) fn shape(self, mode: Mode) -> CursorShape {
match mode {
Mode::Normal => self.normal,
Mode::Insert => self.insert,
Mode::Command => self.command,
Mode::Search => self.search,
Mode::Visual => self.visual,
}
}
}
+800 -133
View File
File diff suppressed because it is too large Load Diff
+591 -67
View File
File diff suppressed because it is too large Load Diff
+446 -190
View File
File diff suppressed because it is too large Load Diff
+469 -17
View File
@@ -13,7 +13,17 @@ pub(crate) struct PullRequestSummary {
pub title: String, pub title: String,
pub description: String, pub description: String,
pub closed: bool, pub closed: bool,
pub draft: bool,
pub approved: bool,
pub review_requested: bool,
pub created_at: String, pub created_at: String,
pub updated_at: String,
}
struct PullRequestMetadata {
size: Option<(u64, u64)>,
approved: bool,
review_requested: bool,
} }
pub(crate) struct PullRequestPage { pub(crate) struct PullRequestPage {
@@ -42,6 +52,7 @@ struct Forge {
provider: Provider, provider: Provider,
host: String, host: String,
endpoint: String, endpoint: String,
repository: String,
} }
fn encode(text: &str) -> String { fn encode(text: &str) -> String {
@@ -237,6 +248,7 @@ impl Forge {
provider, provider,
host: authority, host: authority,
endpoint, endpoint,
repository: path.to_owned(),
}) })
} }
@@ -308,14 +320,18 @@ impl Forge {
.transpose() .transpose()
} }
fn get( fn request(
&self, &self,
url: &str, url: &str,
body: Option<&str>,
token: Option<&str>, token: Option<&str>,
root: &Path, root: &Path,
cancelled: &AtomicBool, cancelled: &AtomicBool,
) -> Result<Value, String> { ) -> Result<Value, String> {
let mut headers = String::from("Accept: application/json\n"); let mut headers = String::from("Accept: application/json\n");
if body.is_some() {
headers.push_str("Content-Type: application/json\n");
}
if let Some(token) = token { if let Some(token) = token {
let prefix = match self.provider { let prefix = match self.provider {
Provider::Github => "Authorization: Bearer", Provider::Github => "Authorization: Bearer",
@@ -347,6 +363,9 @@ impl Forge {
"--url", "--url",
url, url,
]); ]);
if let Some(body) = body {
command.args(["--data-binary", body]);
}
let bytes = checked(run(command, headers.into_bytes(), cancelled)?).map_err( let bytes = checked(run(command, headers.into_bytes(), cancelled)?).map_err(
|_| "Forge API request failed; check curl, network access, and authentication", |_| "Forge API request failed; check curl, network access, and authentication",
)?; )?;
@@ -374,7 +393,7 @@ impl Forge {
&self, &self,
closed: bool, closed: bool,
page: u32, page: u32,
mut get: impl FnMut(&str) -> Result<Value, String>, mut request: impl FnMut(&str, Option<&str>) -> Result<Value, String>,
) -> Result<PullRequestPage, String> { ) -> Result<PullRequestPage, String> {
if page == 0 { if page == 0 {
return Err("Forge page numbers start at 1".into()); return Err("Forge page numbers start at 1".into());
@@ -394,17 +413,29 @@ impl Forge {
// Gitea's default sort is descending creation time; "oldest" reverses it. // Gitea's default sort is descending creation time; "oldest" reverses it.
Provider::Gitea => "limit=50", Provider::Gitea => "limit=50",
}; };
let values = get(&format!( let values = request(
"{}?state={state}&{query}&page={page}", &format!("{}?state={state}&{query}&page={page}", self.endpoint),
self.endpoint None,
))?; )?;
let values = values let values = values
.as_array() .as_array()
.ok_or("Forge API returned a non-array pull request list")?; .ok_or("Forge API returned a non-array pull request list")?;
// Servers may cap page sizes. Only an empty page proves exhaustion. // Servers may cap page sizes. Only an empty page proves exhaustion.
has_more |= !values.is_empty(); has_more |= !values.is_empty();
let metadata = self.metadata_with(values, &mut request);
for value in values { for value in values {
let entry = summary(self.provider, value)?; let id = number(
value,
if self.provider == Provider::Gitlab {
"iid"
} else {
"number"
},
)?;
let details = metadata.get(&id);
let mut entry = summary(self.provider, value, details.and_then(|d| d.size))?;
entry.approved = details.is_some_and(|d| d.approved);
entry.review_requested = details.is_some_and(|d| d.review_requested);
if entry.closed != closed { if entry.closed != closed {
return Err("Forge API returned a pull request in the wrong category".into()); return Err("Forge API returned a pull request in the wrong category".into());
} }
@@ -422,6 +453,407 @@ impl Forge {
Ok(PullRequestPage { entries, has_more }) Ok(PullRequestPage { entries, has_more })
} }
/// Batch GitHub/GitLab metadata; Gitea exposes reviews only per pull request.
fn metadata_with(
&self,
values: &[Value],
request: &mut impl FnMut(&str, Option<&str>) -> Result<Value, String>,
) -> BTreeMap<u64, PullRequestMetadata> {
if values.is_empty() {
return BTreeMap::new();
}
let api = self
.endpoint
.split_once(if self.provider == Provider::Gitlab {
"/projects/"
} else {
"/repos/"
})
.unwrap()
.0;
let viewer = request(&format!("{api}/user"), None).ok();
let username = viewer.as_ref().and_then(|viewer| {
viewer
.get(if self.provider == Provider::Gitlab {
"username"
} else {
"login"
})
.and_then(Value::as_str)
.filter(|name| !name.is_empty())
});
let mut metadata: BTreeMap<_, _> = values
.iter()
.filter_map(|value| {
let id = number(
value,
if self.provider == Provider::Gitlab {
"iid"
} else {
"number"
},
)
.ok()?;
Some((
id,
PullRequestMetadata {
size: value
.get("additions")
.and_then(Value::as_u64)
.zip(value.get("deletions").and_then(Value::as_u64)),
approved: false,
// GitHub's list contains only outstanding direct requests, not teams.
review_requested: self.provider == Provider::Github
&& username.is_some_and(|username| {
value
.get("requested_reviewers")
.and_then(Value::as_array)
.is_some_and(|reviewers| {
reviewers.iter().any(|reviewer| {
reviewer
.get("login")
.and_then(Value::as_str)
.is_some_and(|login| {
login.eq_ignore_ascii_case(username)
})
})
})
}),
},
))
})
.collect();
if self.provider == Provider::Gitea {
if let Some(viewer) = viewer.as_ref().and_then(|viewer| number(viewer, "id").ok()) {
for (&id, details) in &mut metadata {
// ponytail: Gitea has no batch reviews API; per-PR reads until it does.
// List requested_reviewers also includes completed reviews.
(details.approved, details.review_requested) = self
.gitea_reviews_with(id, viewer, request)
.unwrap_or_default();
}
}
return metadata;
}
if self.provider == Provider::Gitlab {
if let Some(username) = username {
self.gitlab_requests_with(values, username, &mut metadata, request);
}
}
let reviews = if self.provider == Provider::Github {
username.map_or_else(String::new, |username| {
format!(
"reviews(author:{},states:[APPROVED,CHANGES_REQUESTED,DISMISSED],last:1){{nodes{{state author{{login}}}}}}",
serde_json::to_string(username).unwrap()
)
})
} else {
String::new()
};
let mut fields = String::new();
for value in values {
let Some(id) = value
.get(if self.provider == Provider::Gitlab {
"iid"
} else {
"number"
})
.and_then(Value::as_u64)
else {
continue;
};
use std::fmt::Write;
match self.provider {
Provider::Github => write!(
fields,
"p{id}:pullRequest(number:{id}){{additions deletions {reviews}}}"
)
.unwrap(),
Provider::Gitlab => write!(
fields,
"p{id}:mergeRequest(iid:\"{id}\"){{diffStatsSummary{{additions deletions}}}}"
)
.unwrap(),
Provider::Gitea => unreachable!(),
}
}
let approval_args = if self.provider == Provider::Gitlab {
username.map(|username| {
let ids: Vec<_> = values
.iter()
.filter_map(|value| value.get("iid")?.as_u64().map(|id| id.to_string()))
.collect();
format!(
"iids:{},approvedBy:[{}],first:{}",
serde_json::to_string(&ids).unwrap(),
serde_json::to_string(username).unwrap(),
ids.len()
)
})
} else {
None
};
if let Some(args) = &approval_args {
use std::fmt::Write;
write!(
fields,
"approved:mergeRequests({args}){{nodes{{iid}} pageInfo{{hasNextPage endCursor}}}}"
)
.unwrap();
}
let (url, query) = if self.provider == Provider::Github {
let (owner, name) = self.repository.split_once('/').unwrap();
let api = self.endpoint.split_once("/repos/").unwrap().0;
(
format!("{}/graphql", api.strip_suffix("/v3").unwrap_or(api)),
format!(
"query{{repository(owner:{},name:{}){{{fields}}}}}",
serde_json::to_string(owner).unwrap(),
serde_json::to_string(name).unwrap()
),
)
} else {
(
format!("https://{}/api/graphql", self.host),
format!(
"query{{project(fullPath:{}){{{fields}}}}}",
serde_json::to_string(&self.repository).unwrap()
),
)
};
let body = serde_json::json!({ "query": query }).to_string();
// Missing/denied metadata must not discard independently known list data.
let Ok(response) = request(&url, Some(&body)) else {
return metadata;
};
let container = if self.provider == Provider::Github {
"/data/repository"
} else {
"/data/project"
};
let Some(entries) = response.pointer(container).and_then(Value::as_object) else {
return metadata;
};
for (alias, value) in entries {
let Some(details) = alias
.strip_prefix('p')
.and_then(|id| id.parse::<u64>().ok())
.and_then(|id| metadata.get_mut(&id))
else {
continue;
};
let stats = if self.provider == Provider::Gitlab {
value.get("diffStatsSummary")
} else {
Some(value)
};
details.size = stats
.and_then(|stats| {
stats
.get("additions")?
.as_u64()
.zip(stats.get("deletions")?.as_u64())
})
.or(details.size);
details.approved = self.provider == Provider::Github
&& username.is_some_and(|username| {
value
.pointer("/reviews/nodes")
.and_then(Value::as_array)
.is_some_and(|reviews| {
reviews.len() == 1
&& reviews[0]["state"] == "APPROVED"
&& reviews[0]
.pointer("/author/login")
.and_then(Value::as_str)
.is_some_and(|author| author.eq_ignore_ascii_case(username))
})
});
}
if let Some(args) = approval_args {
let mut response = response;
let mut cursor = None::<String>;
loop {
let Some(approvals) = response.pointer("/data/project/approved") else {
break;
};
if let Some(nodes) = approvals.get("nodes").and_then(Value::as_array) {
for node in nodes {
if let Some(details) = node
.get("iid")
.and_then(Value::as_str)
.and_then(|id| id.parse::<u64>().ok())
.and_then(|id| metadata.get_mut(&id))
{
details.approved = true;
}
}
}
if approvals.pointer("/pageInfo/hasNextPage") != Some(&Value::Bool(true)) {
break;
}
let Some(after) = approvals
.pointer("/pageInfo/endCursor")
.and_then(Value::as_str)
.filter(|after| !after.is_empty() && Some(*after) != cursor.as_deref())
else {
break;
};
cursor = Some(after.to_owned());
let body = serde_json::json!({
"query": format!(
"query{{project(fullPath:{}){{approved:mergeRequests({args},after:{}){{nodes{{iid}} pageInfo{{hasNextPage endCursor}}}}}}}}",
serde_json::to_string(&self.repository).unwrap(),
serde_json::to_string(after).unwrap()
)
}).to_string();
match request(&url, Some(&body)) {
Ok(next) => response = next,
Err(_) => break,
}
}
}
metadata
}
fn gitlab_requests_with(
&self,
values: &[Value],
username: &str,
metadata: &mut BTreeMap<u64, PullRequestMetadata>,
request: &mut impl FnMut(&str, Option<&str>) -> Result<Value, String>,
) {
let mut pending: BTreeMap<_, _> = values
.iter()
.filter_map(|value| {
let reviewers = value.get("reviewers")?.as_array()?;
if !reviewers.iter().any(|reviewer| {
reviewer.get("username").and_then(Value::as_str) == Some(username)
}) {
return None;
}
Some((number(value, "iid").ok()?, None::<String>))
})
.collect();
// Keep optional reviewer-state reads separate: unsupported/denied fields
// must not invalidate the independent statistics and approval query.
while !pending.is_empty() {
use std::fmt::Write;
let mut fields = String::new();
// Ten aliases stay below GitLab's default query-complexity limit.
let batch: BTreeMap<_, _> = (0..10).filter_map(|_| pending.pop_first()).collect();
for (id, cursor) in &batch {
let after = cursor.as_ref().map_or_else(String::new, |cursor| {
format!(",after:{}", serde_json::to_string(cursor).unwrap())
});
write!(fields, "p{id}:mergeRequest(iid:\"{id}\"){{reviewers(first:20{after}){{nodes{{username mergeRequestInteraction{{reviewState}}}} pageInfo{{hasNextPage endCursor}}}}}}").unwrap();
}
let body = serde_json::json!({
"query": format!("query{{project(fullPath:{}){{{fields}}}}}",
serde_json::to_string(&self.repository).unwrap())
})
.to_string();
let Ok(response) = request(&format!("https://{}/api/graphql", self.host), Some(&body))
else {
continue;
};
for (id, cursor) in batch {
let Some(reviewers) = response.pointer(&format!("/data/project/p{id}/reviewers"))
else {
continue;
};
let own = reviewers
.get("nodes")
.and_then(Value::as_array)
.and_then(|nodes| {
nodes.iter().find(|node| {
node.get("username").and_then(Value::as_str) == Some(username)
})
});
if let Some(own) = own {
if let Some(details) = metadata.get_mut(&id) {
details.review_requested = matches!(
own.pointer("/mergeRequestInteraction/reviewState")
.and_then(Value::as_str),
Some("UNREVIEWED" | "REVIEW_STARTED")
);
}
} else if reviewers.pointer("/pageInfo/hasNextPage") == Some(&Value::Bool(true)) {
if let Some(after) = reviewers
.pointer("/pageInfo/endCursor")
.and_then(Value::as_str)
.filter(|after| !after.is_empty() && Some(*after) != cursor.as_deref())
{
pending.insert(id, Some(after.to_owned()));
}
}
}
}
}
fn gitea_reviews_with(
&self,
id: u64,
viewer: u64,
request: &mut impl FnMut(&str, Option<&str>) -> Result<Value, String>,
) -> Result<(bool, bool), String> {
let mut latest = None::<(u64, bool)>;
let mut requested = None::<u64>;
let mut submitted = 0;
for page in 1.. {
let response = request(
&format!("{}/{id}/reviews?limit=50&page={page}", self.endpoint),
None,
)?;
let reviews = response
.as_array()
.ok_or("Forge API returned a non-array review list")?;
if reviews.is_empty() {
return Ok((
latest.is_some_and(|(_, approved)| approved),
requested.is_some_and(|id| id > submitted),
));
}
for review in reviews {
if review.pointer("/user/id").and_then(Value::as_u64) != Some(viewer)
|| review.get("team").is_some_and(|team| !team.is_null())
{
continue;
}
let state = field(review, "state")?;
match state {
"PENDING" => continue,
"REQUEST_REVIEW" => {
if review.get("dismissed").and_then(Value::as_bool) == Some(false) {
if let Some(id) = review.get("id").and_then(Value::as_u64) {
requested = Some(requested.map_or(id, |previous| previous.max(id)));
}
}
continue;
}
"COMMENT" => {
if let Some(id) = review.get("id").and_then(Value::as_u64) {
submitted = submitted.max(id);
}
continue;
}
"APPROVED" | "REQUEST_CHANGES" => {}
_ => return Err("Forge API returned invalid review state".into()),
}
let id = number(review, "id")?;
submitted = submitted.max(id);
let dismissed = review
.get("dismissed")
.and_then(Value::as_bool)
.ok_or("Forge API returned invalid dismissed flag")?;
if latest.is_none_or(|(previous, _)| id > previous) {
latest = Some((id, state == "APPROVED" && !dismissed));
}
}
}
unreachable!()
}
fn resolve_with( fn resolve_with(
&self, &self,
id: u64, id: u64,
@@ -477,9 +909,9 @@ fn gitea_head(value: &Value, id: u64) -> Result<String, String> {
sha(object, "sha") sha(object, "sha")
} }
fn created_at(value: &Value) -> Result<String, String> { fn timestamp(value: &Value, key: &str) -> Result<String, String> {
let invalid = || "Forge API returned invalid created_at".to_owned(); let invalid = || format!("Forge API returned invalid {key}");
let text = field(value, "created_at")?; let text = field(value, key)?;
let digits = |text: &str| -> Option<i32> { let digits = |text: &str| -> Option<i32> {
(!text.is_empty() && text.bytes().all(|b| b.is_ascii_digit())) (!text.is_empty() && text.bytes().all(|b| b.is_ascii_digit()))
.then(|| text.parse().ok()) .then(|| text.parse().ok())
@@ -567,17 +999,33 @@ fn created_at(value: &Value) -> Result<String, String> {
)) ))
} }
fn summary(provider: Provider, value: &Value) -> Result<PullRequestSummary, String> { fn summary(
provider: Provider,
value: &Value,
size: Option<(u64, u64)>,
) -> Result<PullRequestSummary, String> {
let gitlab = provider == Provider::Gitlab; let gitlab = provider == Provider::Gitlab;
let (closed, merged) = status(provider, value)?; let (closed, merged) = status(provider, value)?;
let draft = match value.get("draft") {
None => false,
Some(value) => value
.as_bool()
.ok_or("Forge API returned invalid draft flag")?,
};
let author = author(provider, value)?; let author = author(provider, value)?;
let state = if merged { let state = if merged {
"merged" "merged"
} else if closed { } else if closed {
"closed" "closed"
} else if draft {
"draft"
} else { } else {
"open" "open"
}; };
let size = size.map_or_else(
|| "size unavailable".into(),
|(added, deleted)| format!("+{added} -{deleted}"),
);
let (base, head) = if gitlab { let (base, head) = if gitlab {
( (
value.get("target_branch").and_then(Value::as_str), value.get("target_branch").and_then(Value::as_str),
@@ -601,11 +1049,15 @@ fn summary(provider: Provider, value: &Value) -> Result<PullRequestSummary, Stri
number: number(value, if gitlab { "iid" } else { "number" })?, number: number(value, if gitlab { "iid" } else { "number" })?,
title: field(value, "title")?.to_owned(), title: field(value, "title")?.to_owned(),
description: match (base, head) { description: match (base, head) {
(Some(base), Some(head)) => format!("{author} · {state} · {head} → {base}"), (Some(base), Some(head)) => format!("{author} · {state} · {size} · {head} → {base}"),
_ => format!("{author} · {state}"), _ => format!("{author} · {state} · {size}"),
}, },
closed, closed,
created_at: created_at(value)?, draft,
approved: false,
review_requested: false,
created_at: timestamp(value, "created_at")?,
updated_at: timestamp(value, "updated_at")?,
}) })
} }
@@ -788,8 +1240,8 @@ pub(crate) fn page(
ssh_hostname(command, destination, port, cancelled) ssh_hostname(command, destination, port, cancelled)
})?; })?;
let token = forge.token(root, cancelled)?; let token = forge.token(root, cancelled)?;
forge.page_with(closed, page, |url| { forge.page_with(closed, page, |url, body| {
forge.get(url, token.as_deref(), root, cancelled) forge.request(url, body, token.as_deref(), root, cancelled)
}) })
} }
@@ -807,7 +1259,7 @@ pub(crate) fn resolve(
})?; })?;
let token = forge.token(root, cancelled)?; let token = forge.token(root, cancelled)?;
forge.resolve_with(number, |url| { forge.resolve_with(number, |url| {
forge.get(url, token.as_deref(), root, cancelled) forge.request(url, None, token.as_deref(), root, cancelled)
}) })
} }
+4
View File
@@ -2,6 +2,7 @@ mod battery;
mod blame; mod blame;
pub mod chrome; pub mod chrome;
pub mod clipboard; pub mod clipboard;
pub(crate) mod cursor;
pub mod diff; pub mod diff;
pub mod editor; pub mod editor;
pub mod files; pub mod files;
@@ -12,11 +13,14 @@ mod lsp_config;
mod markdown; mod markdown;
pub mod panes; pub mod panes;
pub mod plugin; pub mod plugin;
mod pull_request_preferences;
mod pull_requests;
pub mod repository; pub mod repository;
pub mod resolve; pub mod resolve;
pub mod review; pub mod review;
pub mod ruby; pub mod ruby;
pub mod session; pub mod session;
mod shell;
mod snippet; mod snippet;
pub mod syntax; pub mod syntax;
mod terminal_colors; mod terminal_colors;
+1
View File
@@ -14,6 +14,7 @@ pub fn resolve(value: Value) -> Result<Vec<ServerConfig>, String> {
crate::syntax::RUST_ID => Some((&[&["rust-analyzer"]], "rs")), crate::syntax::RUST_ID => Some((&[&["rust-analyzer"]], "rs")),
crate::syntax::PYTHON_ID => Some((&[&["ty", "server"], &["ruff", "server"]], "py")), crate::syntax::PYTHON_ID => Some((&[&["ty", "server"], &["ruff", "server"]], "py")),
crate::syntax::RUBY_ID => Some((&[&["solargraph", "stdio"]], "rb")), crate::syntax::RUBY_ID => Some((&[&["solargraph", "stdio"]], "rb")),
crate::syntax::TYPST_ID => Some((&[&["tinymist", "lsp"]], "typ")),
_ => None, _ => None,
}; };
let mut options = match value { let mut options = match value {
+27 -10
View File
@@ -12,8 +12,11 @@ const HELP: &str = "vamp — modal editor with isolated Ruby plugins
Usage: vamp [--config PATH | --no-config] [FILE] Usage: vamp [--config PATH | --no-config] [FILE]
vamp [--config PATH | --no-config] --review list [--repo PATH] [--json] vamp [--config PATH | --no-config] --review list [--repo PATH] [--json]
vamp [--config PATH | --no-config] --review show ID [--repo PATH] [--json] [--all] vamp [--config PATH | --no-config] --review show ID [--repo PATH] [--json] [--all]
vamp [--config PATH | --no-config] --review delete REVIEW_ID COMMENT_ID [--repo PATH] [--json]
vamp [--config PATH | --no-config] --review resolve REVIEW_ID COMMENT_ID [--repo PATH] [--json]
Review CLI: read-only local storage; show defaults to unresolved comments; --all includes resolved. Review CLI: list/show are read-only; delete/resolve commit one comment change immediately.
Show defaults to unresolved comments; --all includes resolved.
Use vamp -- --review to edit a file named --review. Use vamp -- --review to edit a file named --review.
Default keys (configurable with config.bind): Default keys (configurable with config.bind):
@@ -56,9 +59,13 @@ Insert: Ctrl+n requests LSP completion at primary caret, then cycles next; Ctrl+
Snippets expand fully: type replaces argument, Tab/Shift+Tab next/previous, final $0 ends Snippets expand fully: type replaces argument, Tab/Shift+Tab next/previous, final $0 ends
Outside snippets Enter inserts newline, Tab inserts a tab or configured spaces; Esc returns to Normal Outside snippets Enter inserts newline, Tab inserts a tab or configured spaces; Esc returns to Normal
Insert/Command/Search: Ctrl+u deletes backward to the logical line start Insert/Command/Search: Ctrl+u deletes backward to the logical line start
Command/Search: Up/Ctrl+k older history; Down/Ctrl+j newer history or unfinished input; Enter submits
Command: Tab/Shift+Tab completes command names and cycles forward/backward with wraparound (not arguments)
Search: Enter accepts current/next literal match, Esc cancels (case-sensitive, wraps) Search: Enter accepts current/next literal match, Esc cancels (case-sensitive, wraps)
Commands: :save [PATH], :force_save [PATH], :quit, :force_quit, Commands: :save [PATH], :force_save [PATH], :quit, :force_quit,
:save_and_quit [PATH], :force_save_and_quit [PATH], :plugins, :PLUGIN [ARGS] :save_and_quit [PATH], :force_save_and_quit [PATH], :plugins, :PLUGIN [ARGS]
Shell: :!COMMAND runs asynchronously in the project directory using $SHELL (or /bin/sh)
Pending command/spinner in top bar; output/exit status in floating window; Enter/Esc closes
Aliases: :w=save, :w!=force_save, :q=quit, :q!=force_quit, Aliases: :w=save, :w!=force_save, :q=quit, :q!=force_quit,
:wq=save_and_quit, :wq!=force_save_and_quit :wq=save_and_quit, :wq!=force_save_and_quit
Save actions: core.save, core.force_save, core.save_and_quit, core.force_save_and_quit Save actions: core.save, core.force_save, core.save_and_quit, core.force_save_and_quit
@@ -78,7 +85,7 @@ Restart: :restart closes clean buffers; unsaved buffers remain open and block re
Tabs: :tab.new, :tab.previous, :tab.next, :tab.close, :tab.force_close Tabs: :tab.new, :tab.previous, :tab.next, :tab.close, :tab.force_close
Tabs retain pane layouts and independent views; close protects exclusively displayed unsaved edits Tabs retain pane layouts and independent views; close protects exclusively displayed unsaved edits
Markdown links: :follow_link (primary caret; local files only) Markdown links: :follow_link (primary caret; local files only)
Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers Diff: :diff (Normal); floating revision/bookmark lists; choose Before then After; Before marked inline
Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus
All text panes use native motions, selections and copy; read-only panes reject edits/saves All text panes use native motions, selections and copy; read-only panes reject edits/saves
Switching Before/After preserves the aligned cursor row and display column Switching Before/After preserves the aligned cursor row and display column
@@ -86,20 +93,29 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
Normal [/] explicitly expands context; Insert brackets remain literal text Normal [/] explicitly expands context; Insert brackets remain literal text
Normal Space v cycles split/unified; Space s flips left/right vs above/below split Normal Space v cycles split/unified; Space s flips left/right vs above/below split
Defaults: config.diff_view_mode = \"split\"; config.diff_split_orientation = \"vertical\" Defaults: config.diff_view_mode = \"split\"; config.diff_split_orientation = \"vertical\"
Working After follows descendant checkouts; config.diff_follow_working_copy = false disables it Follow checkout After edits live buffers and follows descendants; config.diff_follow_working_copy = false disables following
Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly
Remote refs/history load into temporary comparison repos without changing active refs/checkout Remote refs/history load into temporary comparison repos without changing active refs/checkout
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
Local reviews: :diff.comment 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
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
:diff.review.edit/delete [ID], :diff.review.next/previous, :diff.review.overview :review.edit/delete [ID], :review.next/previous, :review.overview
:diff.review.summary; :diff.review.verdict looks_good|changes_requested|none :review.summary; :review.verdict looks_good|changes_requested|none
Local SQLite autosave on dismissal/switch; verdicts/deletions commit immediately; :w commits explicitly :review.copy copies all saved comments in CLI format using the configured clipboard
:diff.review.save PATH exports JSON; :diff.review.open [ID|PATH] selects/imports; :diff.review.new :w saves comment/summary text, including hidden drafts; verdicts/deletions commit immediately
Normal Esc or :pane.close commits and dismisses the floating editor; failures retain drafts :review.save PATH exports JSON; :review.open [ID|PATH] selects/imports; :review.new
Normal Esc or :pane.close discards unsaved text and dismisses the floating editor
Snapshot anchors stay immutable; stale comments lose gutter markers; no publishing Snapshot anchors stay immutable; stale comments lose gutter markers; no publishing
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/All tabs, type to search Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/Drafts/All tabs, type to search
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
🙏 marks a personal review request; 👍 marks your active approval (API account)
Ctrl+s sort menu: 1-4 precedence, Backspace clear, Left/Right date direction
Ctrl+f filter menu: Space includes/excludes personal review states; Enter applies, Esc cancels
Sorting persists; filters last for this session; config.pull_request_sort / config.pull_request_filters set startup choices
Symbols: config.pull_request_review_requested_symbol / config.pull_request_approved_symbol
Resolve jj conflicts: :resolve opens the current working-copy conflicts in a dedicated workspace Resolve jj conflicts: :resolve opens the current working-copy conflicts in a dedicated workspace
Editable marker-free Draft vs readonly Alternative; :resolve.history shows inputs/base Editable marker-free Draft vs readonly Alternative; :resolve.history shows inputs/base
config.resolve_history_layout = \"columns\" (left/base/right) or \"base_below\" (left/right above base) config.resolve_history_layout = \"columns\" (left/base/right) or \"base_below\" (left/right above base)
@@ -113,6 +129,7 @@ Set VAMP_RUBY to choose the Ruby executable.
Native LSP is disabled by default: config.lsp[\"rust\"] = true enables a preset Native LSP is disabled by default: config.lsp[\"rust\"] = true enables a preset
Presets: rust -> rust-analyzer, python -> ty server + ruff server, ruby -> solargraph stdio Presets: rust -> rust-analyzer, python -> ty server + ruff server, ruby -> solargraph stdio
typst -> tinymist lsp
Custom languages: config.lsp[\"my-language\"] = { command: [...], extensions: [...] } Custom languages: config.lsp[\"my-language\"] = { command: [...], extensions: [...] }
Server stacks: config.lsp[\"python\"] = { servers: [{ command: [...] }, { command: [...] }] } Server stacks: config.lsp[\"python\"] = { servers: [{ command: [...] }, { command: [...] }] }
LSP commands: :lsp.definition, :lsp.diagnostics, :lsp.hover LSP commands: :lsp.definition, :lsp.diagnostics, :lsp.hover
+168
View File
@@ -0,0 +1,168 @@
use crate::pull_requests::{Preferences, SortSettings};
use serde::{Deserialize, Serialize};
use std::{
ffi::OsStr,
fs::{self, OpenOptions},
io::{self, Write},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
static NEXT_TEMP: AtomicU64 = AtomicU64::new(1);
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredPreferences<T> {
version: u8,
preferences: T,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredSort<T> {
#[serde(default)]
sort: T,
// Previously saved filters are deliberately ignored, including invalid values.
#[serde(default, rename = "filters", skip_serializing)]
_filters: serde::de::IgnoredAny,
}
pub(crate) fn default_path() -> Result<PathBuf, String> {
path_from(
std::env::var_os("XDG_STATE_HOME").as_deref(),
std::env::var_os("HOME").as_deref(),
)
}
fn path_from(xdg: Option<&OsStr>, home: Option<&OsStr>) -> Result<PathBuf, String> {
if let Some(path) = xdg.map(Path::new).filter(|path| path.is_absolute()) {
return Ok(path.join("vamp/pull_requests.json"));
}
home.map(Path::new)
.filter(|path| path.is_absolute())
.map(|path| path.join(".local/state/vamp/pull_requests.json"))
.ok_or_else(|| {
"Cannot locate pull request preferences: HOME must be an absolute directory".into()
})
}
fn read_preferences(path: &Path) -> Result<Option<Preferences>, String> {
match fs::symlink_metadata(path) {
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.to_string()),
Ok(metadata) if !metadata.is_file() => {
return Err("preferences must be a regular file (not a symlink)".into());
}
Ok(_) => {}
}
let bytes = fs::read(path).map_err(|error| error.to_string())?;
let stored: StoredPreferences<StoredSort<SortSettings>> =
serde_json::from_slice(&bytes).map_err(|error| error.to_string())?;
if stored.version != 1 {
return Err(format!(
"unsupported preferences version {}",
stored.version
));
}
let preferences = Preferences {
sort: stored.preferences.sort,
..Preferences::default()
};
preferences.validate()?;
Ok(Some(preferences))
}
pub(crate) struct Store {
path: Option<PathBuf>,
blocked: Option<String>,
}
impl Store {
pub(crate) fn load(path: Result<PathBuf, String>) -> (Self, Preferences, Option<String>) {
let result = match &path {
Ok(path) => read_preferences(path),
Err(error) => Err(error.clone()),
};
let (preferences, blocked) = match result {
Ok(preferences) => (preferences.unwrap_or_default(), None),
Err(error) => (Preferences::default(), Some(error)),
};
let store = Self {
path: path.ok(),
blocked,
};
let notice = store.blocked.as_ref().map(|error| store.notice(error));
(store, preferences, notice)
}
pub(crate) fn disabled() -> Self {
Self {
path: None,
blocked: None,
}
}
fn notice(&self, error: &str) -> String {
let location = self.path.as_ref().map_or_else(
|| "Pull request preferences".to_owned(),
|path| format!("Pull request preferences ({})", path.display()),
);
format!("{location}: {error}. Changes are session-only; existing data was preserved.")
}
pub(crate) fn save(&self, preferences: &Preferences) -> Result<(), String> {
self.save_inner(preferences)
.map_err(|error| self.notice(&error))
}
fn save_inner(&self, preferences: &Preferences) -> Result<(), String> {
if let Some(error) = &self.blocked {
return Err(error.clone());
}
preferences.validate()?;
let Some(path) = &self.path else {
return Ok(());
};
// Refuse to overwrite data that became corrupt or incompatible after startup.
read_preferences(path)?;
let bytes = serde_json::to_vec_pretty(&StoredPreferences {
version: 1,
preferences: StoredSort {
sort: &preferences.sort,
_filters: serde::de::IgnoredAny,
},
})
.map_err(|error| error.to_string())?;
let parent = path.parent().ok_or("preferences path has no parent")?;
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
let (temporary, mut file) = loop {
let id = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
let temporary = parent.join(format!(".pull-requests-{}-{id}.tmp", std::process::id()));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(&temporary) {
Ok(file) => break (temporary, file),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error.to_string()),
}
};
let result = (|| {
file.write_all(&bytes).and_then(|_| file.sync_all())?;
drop(file);
read_preferences(path).map_err(io::Error::other)?;
// ponytail: optimistic validation; OS compare-and-swap if concurrent writers matter.
fs::rename(&temporary, path)
})();
let _ = fs::remove_file(temporary);
result.map_err(|error| error.to_string())
}
}
#[cfg(test)]
#[path = "../tests/unit/pull_request_preferences.rs"]
mod tests;
+144
View File
@@ -0,0 +1,144 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct Preferences {
pub sort: SortSettings,
pub filters: Vec<ReviewFilter>,
}
impl Default for Preferences {
fn default() -> Self {
Self {
sort: BTreeMap::new(),
filters: ReviewFilter::ALL.to_vec(),
}
}
}
pub(crate) type SortSettings = BTreeMap<SortField, SortRule>;
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum SortField {
Requested,
UpdatedAt,
Approved,
CreatedAt,
}
impl SortField {
pub const ALL: [Self; 4] = [
Self::Requested,
Self::UpdatedAt,
Self::Approved,
Self::CreatedAt,
];
pub fn label(self) -> &'static str {
match self {
Self::Requested => "Requested from me",
Self::UpdatedAt => "Updated at",
Self::Approved => "Approved by me",
Self::CreatedAt => "Created at",
}
}
pub fn is_date(self) -> bool {
matches!(self, Self::UpdatedAt | Self::CreatedAt)
}
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct SortRule {
pub precedence: Option<u8>,
pub direction: Direction,
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Direction {
Asc,
#[default]
Desc,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ReviewFilter {
Requested,
Approved,
Other,
}
impl ReviewFilter {
pub const ALL: [Self; 3] = [Self::Requested, Self::Approved, Self::Other];
pub fn label(self) -> &'static str {
match self {
Self::Requested => "Requested from me",
Self::Approved => "Approved by me",
Self::Other => "Other / unavailable",
}
}
}
impl Preferences {
pub fn validate(&self) -> Result<(), String> {
let mut ranks = [false; 4];
for rule in self.sort.values() {
if let Some(rank) = rule.precedence {
if !(1..=4).contains(&rank) {
return Err("Sort precedence must be between 1 and 4".into());
}
if std::mem::replace(&mut ranks[usize::from(rank - 1)], true) {
return Err("Sort precedence must be unique".into());
}
}
}
for (index, filter) in self.filters.iter().enumerate() {
if self.filters[..index].contains(filter) {
return Err("Review filters must be unique".into());
}
}
Ok(())
}
pub fn ordered_fields(&self) -> [SortField; 4] {
let mut slots = [None; 4];
for (&field, rule) in &self.sort {
if let Some(rank) = rule.precedence {
slots[usize::from(rank - 1)] = Some(field);
}
}
let mut unranked = SortField::ALL.into_iter().filter(|field| {
self.sort
.get(field)
.is_none_or(|rule| rule.precedence.is_none())
});
slots.map(|field| field.unwrap_or_else(|| unranked.next().expect("four sort fields")))
}
pub fn set_rank(&mut self, field: SortField, rank: Option<u8>) {
self.sort.entry(field).or_default().precedence = None;
let Some(mut rank) = rank else {
return;
};
let mut field = field;
loop {
let displaced = self
.sort
.iter()
.find_map(|(&other, rule)| (rule.precedence == Some(rank)).then_some(other));
self.sort.entry(field).or_default().precedence = Some(rank);
let Some(other) = displaced else {
break;
};
self.sort.get_mut(&other).expect("ranked field").precedence = None;
field = other;
rank = rank % 4 + 1;
}
}
}
+354 -21
View File
@@ -1,7 +1,8 @@
//! Read-only active-repository discovery and isolated revision comparison. //! Read-only active-repository discovery and isolated revision comparison.
use crate::diff::{ use crate::diff::{
Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus, PullRequestPage, BlameRevision, Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus,
RepoKind, Revision, RevisionTarget, line_hunks, PullRequestMetadata, PullRequestPage, RepoKind, Revision, RevisionGraph, RevisionTarget,
line_hunks,
}; };
use std::{ use std::{
collections::{BTreeMap, BTreeSet}, collections::{BTreeMap, BTreeSet},
@@ -28,7 +29,8 @@ const WORKING_SNAPSHOT_LIMIT: usize = 512 * 1024 * 1024;
const TIMEOUT: Duration = Duration::from_secs(60); const TIMEOUT: Duration = Duration::from_secs(60);
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
struct Temporary(PathBuf); #[derive(Debug, PartialEq, Eq)]
pub(crate) struct Temporary(PathBuf);
impl Temporary { impl Temporary {
fn new() -> Result<Self, String> { fn new() -> Result<Self, String> {
for _ in 0..100 { for _ in 0..100 {
@@ -1054,6 +1056,7 @@ struct PreparedComparison {
selected: [Revision; 2], selected: [Revision; 2],
selected_ids: [Option<String>; 2], selected_ids: [Option<String>; 2],
revisions: [String; 2], revisions: [String; 2],
blame: [Option<BlameRevision>; 2],
mode: ComparisonMode, mode: ComparisonMode,
left: Snapshot, left: Snapshot,
right: Snapshot, right: Snapshot,
@@ -1077,7 +1080,7 @@ pub struct Repository {
root: PathBuf, root: PathBuf,
kind: RepoKind, kind: RepoKind,
store: PathBuf, store: PathBuf,
temporary: Temporary, temporary: Arc<Temporary>,
private: Option<(PathBuf, PathBuf)>, // workspace and Git object store private: Option<(PathBuf, PathBuf)>, // workspace and Git object store
remotes: Vec<Remote>, remotes: Vec<Remote>,
remote_refs: BTreeMap<(String, String), String>, remote_refs: BTreeMap<(String, String), String>,
@@ -1091,6 +1094,24 @@ pub fn discover_root(path: &Path) -> Result<PathBuf, String> {
discover_root_with_cancellation(path, &AtomicBool::new(false)) discover_root_with_cancellation(path, &AtomicBool::new(false))
} }
/// Identify the exact source bytes without writing objects or snapshotting the checkout.
pub(crate) fn source_snapshot_revision(root: &Path, content: &str) -> Result<String, String> {
let mut command = if root.join(".jj").exists() {
git_command(&store_for_jj(root)?)
} else {
let mut command = Command::new("git");
command.arg("-C").arg(root);
command
};
command.args(["hash-object", "--no-filters", "--stdin"]);
let id = text(checked(run(
command,
content.as_bytes().to_vec(),
&AtomicBool::new(false),
)?)?)?;
Ok(format!("working:{}", id.trim()))
}
fn discover_root_with_cancellation(path: &Path, cancelled: &AtomicBool) -> Result<PathBuf, String> { fn discover_root_with_cancellation(path: &Path, cancelled: &AtomicBool) -> Result<PathBuf, String> {
let path = fs::canonicalize(path).map_err(|error| error.to_string())?; let path = fs::canonicalize(path).map_err(|error| error.to_string())?;
let root = path let root = path
@@ -1128,7 +1149,7 @@ impl Repository {
root, root,
kind, kind,
store, store,
temporary: Temporary::new()?, temporary: Arc::new(Temporary::new()?),
private: None, private: None,
remotes: vec![], remotes: vec![],
remote_refs: BTreeMap::new(), remote_refs: BTreeMap::new(),
@@ -1160,10 +1181,25 @@ impl Repository {
root: &Path, root: &Path,
store: &Path, store: &Path,
entries: &mut Vec<Revision>, entries: &mut Vec<Revision>,
graph: &mut BTreeMap<String, RevisionGraph>,
notices: &mut Vec<String>, notices: &mut Vec<String>,
) -> Result<(), String> { ) -> Result<(), String> {
if self.kind == RepoKind::Jujutsu { if self.kind == RepoKind::Jujutsu {
let template = r#""[" ++ json(commit_id) ++ "," ++ json(change_id) ++ "," ++ json(description) ++ "," ++ json(local_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.remote())) ++ "]\n""#; #[derive(serde::Deserialize)]
struct JjRevision(
String,
String,
String,
Vec<String>,
Vec<String>,
Vec<String>,
Vec<String>,
String,
String,
bool,
bool,
);
let template = r#""[" ++ json(commit_id) ++ "," ++ json(change_id) ++ "," ++ json(description) ++ "," ++ json(local_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.name())) ++ "," ++ json(remote_bookmarks.map(|b| b.remote())) ++ "," ++ json(parents.map(|p| p.commit_id())) ++ "," ++ json(author.name()) ++ "," ++ json(author.timestamp().format("%Y-%m-%d %H:%M")) ++ "," ++ json(current_working_copy) ++ "," ++ json(immutable) ++ "]\n""#;
let revset = if root == self.root { let revset = if root == self.root {
"all()" "all()"
} else { } else {
@@ -1172,15 +1208,40 @@ impl Repository {
let output = let output =
text(self.jj(root, &["log", "--no-graph", "-r", revset, "-T", template])?)?; text(self.jj(root, &["log", "--no-graph", "-r", revset, "-T", template])?)?;
for line in output.lines() { for line in output.lines() {
let (id, change, description, bookmarks, remote_names, remotes): ( let JjRevision(
String, id,
String, change,
String, description,
Vec<String>, bookmarks,
Vec<String>, remote_names,
Vec<String>, remotes,
parents,
author,
timestamp,
current,
immutable,
) = serde_json::from_str(line) ) = serde_json::from_str(line)
.map_err(|e| format!("Cannot decode Jujutsu revision: {e}"))?; .map_err(|e| format!("Cannot decode Jujutsu revision: {e}"))?;
graph.entry(id.clone()).or_insert_with(|| RevisionGraph {
change_id: change.clone(),
description: description.trim_end_matches('\n').into(),
parents,
author,
timestamp,
bookmarks: bookmarks
.iter()
.cloned()
.chain(
remote_names
.iter()
.zip(&remotes)
.map(|(name, remote)| format!("{name}@{remote}")),
)
.collect::<Vec<_>>()
.join(" "),
current: current && root == self.root,
immutable,
});
let target = RevisionTarget::Commit(id.clone()); let target = RevisionTarget::Commit(id.clone());
entries.push(Revision { entries.push(Revision {
target: target.clone(), target: target.clone(),
@@ -1350,15 +1411,69 @@ impl Repository {
}; };
let mut entries = vec![Revision { let mut entries = vec![Revision {
target: RevisionTarget::Working, target: RevisionTarget::Working,
label: "Working checkout".into(), label: "Checkout snapshot".into(),
description: description:
"On-disk tracked and eligible untracked files; unsaved buffers are excluded".into(), "On-disk tracked and eligible untracked files; unsaved buffers are excluded".into(),
category, category,
}]; }];
let mut notices = vec![]; let mut notices = vec![];
self.revisions(&self.root, &self.store, &mut entries, &mut notices)?; let mut graph = BTreeMap::new();
self.revisions(
&self.root,
&self.store,
&mut entries,
&mut graph,
&mut notices,
)?;
if let Some((root, store)) = &self.private { if let Some((root, store)) = &self.private {
self.revisions(root, store, &mut entries, &mut notices)?; self.revisions(root, store, &mut entries, &mut graph, &mut notices)?;
}
if self.kind == RepoKind::Jujutsu && self.private.is_some() {
let order: BTreeMap<_, _> = entries
.iter()
.enumerate()
.filter_map(|(index, entry)| match &entry.target {
RevisionTarget::Commit(id) => Some((id.as_str(), index)),
_ => None,
})
.collect();
let mut children: BTreeMap<_, usize> =
graph.keys().map(|id| (id.as_str(), 0)).collect();
for node in graph.values() {
for parent in &node.parents {
if let Some(count) = children.get_mut(parent.as_str()) {
*count += 1;
}
}
}
let mut ready: BTreeSet<_> = children
.iter()
.filter(|(_, count)| **count == 0)
.map(|(&id, _)| (order[id], id))
.collect();
let mut ranks = BTreeMap::new();
while let Some((_, id)) = ready.pop_first() {
ranks.insert(id, ranks.len() + 1);
for parent in &graph[id].parents {
if let Some(count) = children.get_mut(parent.as_str()) {
*count -= 1;
if *count == 0 {
ready.insert((order[parent.as_str()], parent.as_str()));
}
}
}
}
entries.sort_by_key(|entry| match &entry.target {
RevisionTarget::Working => 0,
RevisionTarget::Commit(id) => ranks.get(id.as_str()).copied().unwrap_or(usize::MAX),
RevisionTarget::Bookmark { .. } => entry
.description
.split_whitespace()
.nth(1)
.and_then(|id| ranks.get(id).copied())
.unwrap_or(usize::MAX),
_ => usize::MAX,
});
} }
for remote in &self.remotes { for remote in &self.remotes {
for &view in self.kind.categories() { for &view in self.kind.categories() {
@@ -1419,6 +1534,7 @@ impl Repository {
Ok(Catalog { Ok(Catalog {
kind: self.kind, kind: self.kind,
entries, entries,
graph,
notice: if notices.is_empty() { notice: if notices.is_empty() {
None None
} else { } else {
@@ -1633,11 +1749,18 @@ impl Repository {
description: pull.description, description: pull.description,
category: if pull.closed { category: if pull.closed {
Category::Closed Category::Closed
} else if pull.draft {
Category::Drafts
} else { } else {
Category::Open Category::Open
}, },
}, },
pull.created_at, PullRequestMetadata {
created_at: pull.created_at,
updated_at: pull.updated_at,
requested: pull.review_requested,
approved: pull.approved,
},
) )
}) })
.collect(), .collect(),
@@ -2390,6 +2513,48 @@ impl Repository {
} }
Ok(Some(text(checked(output)?)?.trim().into())) Ok(Some(text(checked(output)?)?.trim().into()))
} }
fn blame_baseline(&mut self, target: &RevisionTarget) -> Result<Option<BlameRevision>, String> {
let source = if *target == RevisionTarget::Working {
let id = if self.kind == RepoKind::Git {
self.working_anchor()?
} else {
let parents = text(self.jj(
&self.root,
&[
"log",
"--no-graph",
"-r",
"@-",
"-T",
"commit_id ++ \"\\n\"",
],
)?)?;
let mut parents = parents.lines();
let parent = parents.next();
// A merge has no single attribution baseline.
if parents.next().is_some() {
None
} else {
parent.map(str::to_owned)
}
};
id.map(|id| (self.store.clone(), id))
} else {
Some(self.resolve(target)?)
};
Ok(source
.filter(|(_, id)| !id.bytes().all(|byte| byte == b'0'))
.map(|(store, commit)| BlameRevision {
keepalive: self
.private
.as_ref()
.filter(|(_, private_store)| private_store == &store)
.map(|_| Arc::clone(&self.temporary)),
store,
commit,
}))
}
fn working_advanced(&self, before: Option<&str>, after: Option<&str>) -> Result<bool, String> { fn working_advanced(&self, before: Option<&str>, after: Option<&str>) -> Result<bool, String> {
if before == after || after.is_none() { if before == after || after.is_none() {
return Ok(false); return Ok(false);
@@ -2492,11 +2657,16 @@ impl Repository {
} else { } else {
self.snapshot_revision(after_target, &right)? self.snapshot_revision(after_target, &right)?
}; };
let blame = [
self.blame_baseline(before_target)?,
self.blame_baseline(after_target)?,
];
let mut prepared = PreparedComparison { let mut prepared = PreparedComparison {
id: comparison_id, id: comparison_id,
selected: [before.clone(), after.clone()], selected: [before.clone(), after.clone()],
selected_ids, selected_ids,
revisions: [before_revision, after_revision], revisions: [before_revision, after_revision],
blame,
mode, mode,
left, left,
right, right,
@@ -2614,10 +2784,12 @@ impl Repository {
} }
let right = self.working_snapshot(buffers)?; let right = self.working_snapshot(buffers)?;
let revision = self.snapshot_revision(&RevisionTarget::Working, &right)?; let revision = self.snapshot_revision(&RevisionTarget::Working, &right)?;
let blame = self.blame_baseline(&RevisionTarget::Working)?;
let mut prepared = self.comparison.take().unwrap(); let mut prepared = self.comparison.take().unwrap();
let previous_right = std::mem::replace(&mut prepared.right, right); let previous_right = std::mem::replace(&mut prepared.right, right);
let previous_files = std::mem::take(&mut prepared.files); let previous_files = std::mem::take(&mut prepared.files);
let previous_revision = std::mem::replace(&mut prepared.revisions[1], revision); let previous_revision = std::mem::replace(&mut prepared.revisions[1], revision);
let previous_blame = std::mem::replace(&mut prepared.blame[1], blame);
let result = self let result = self
.build_comparison(&mut prepared, false) .build_comparison(&mut prepared, false)
.and_then(|comparison| { .and_then(|comparison| {
@@ -2640,6 +2812,7 @@ impl Repository {
prepared.right = previous_right; prepared.right = previous_right;
prepared.files = previous_files; prepared.files = previous_files;
prepared.revisions[1] = previous_revision; prepared.revisions[1] = previous_revision;
prepared.blame[1] = previous_blame;
} }
self.comparison = Some(prepared); self.comparison = Some(prepared);
result.map(|comparison| Some(CheckoutRefresh::Checkout(comparison))) result.map(|comparison| Some(CheckoutRefresh::Checkout(comparison)))
@@ -2671,13 +2844,16 @@ impl Repository {
&& follow_working && follow_working
&& self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())?; && self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())?;
let mut revisions = prepared.revisions.clone(); let mut revisions = prepared.revisions.clone();
let mut blame = prepared.blame.clone();
let (left, before_label) = if branch { let (left, before_label) = if branch {
let base = self.merge_base(ids[0].as_deref().unwrap(), ids[1].as_deref().unwrap())?; let base = self.merge_base(ids[0].as_deref().unwrap(), ids[1].as_deref().unwrap())?;
let label = format!("merge base of {label} and {after_label} · {base}"); let label = format!("merge base of {label} and {after_label} · {base}");
revisions[0] = base.clone(); revisions[0] = base.clone();
blame[0] = self.blame_baseline(&RevisionTarget::Commit(base.clone()))?;
(Some(self.snapshot(&RevisionTarget::Commit(base))?), label) (Some(self.snapshot(&RevisionTarget::Commit(base))?), label)
} else if changed_before { } else if changed_before {
revisions[0] = ids[0].clone().unwrap(); revisions[0] = ids[0].clone().unwrap();
blame[0] = self.blame_baseline(&RevisionTarget::Commit(revisions[0].clone()))?;
( (
Some(self.snapshot(&RevisionTarget::Commit(ids[0].clone().unwrap()))?), Some(self.snapshot(&RevisionTarget::Commit(ids[0].clone().unwrap()))?),
label, label,
@@ -2699,6 +2875,13 @@ impl Repository {
ids[1].clone().unwrap() ids[1].clone().unwrap()
}; };
} }
if right.is_some() {
blame[1] = self.blame_baseline(&if advance_working {
RevisionTarget::Working
} else {
RevisionTarget::Commit(revisions[1].clone())
})?;
}
let pinned_checkout = ids[1] let pinned_checkout = ids[1]
.as_deref() .as_deref()
.map(|id| self.pinned_checkout(id)) .map(|id| self.pinned_checkout(id))
@@ -2711,6 +2894,7 @@ impl Repository {
let previous_before = std::mem::replace(&mut prepared.before, before_label); let previous_before = std::mem::replace(&mut prepared.before, before_label);
let previous_editable = prepared.editable_after; let previous_editable = prepared.editable_after;
let previous_revisions = std::mem::replace(&mut prepared.revisions, revisions); let previous_revisions = std::mem::replace(&mut prepared.revisions, revisions);
let previous_blame = std::mem::replace(&mut prepared.blame, blame);
prepared.editable_after = prepared.editable_after =
working_after || (pinned_checkout.is_some() && pinned_checkout == anchor); working_after || (pinned_checkout.is_some() && pinned_checkout == anchor);
let result = self let result = self
@@ -2747,13 +2931,14 @@ impl Repository {
prepared.before = previous_before; prepared.before = previous_before;
prepared.editable_after = previous_editable; prepared.editable_after = previous_editable;
prepared.revisions = previous_revisions; prepared.revisions = previous_revisions;
prepared.blame = previous_blame;
} }
self.comparison = Some(prepared); self.comparison = Some(prepared);
result result
} }
fn build_comparison( fn build_comparison(
&self, &mut self,
prepared: &mut PreparedComparison, prepared: &mut PreparedComparison,
same: bool, same: bool,
) -> Result<Comparison, String> { ) -> Result<Comparison, String> {
@@ -2770,6 +2955,8 @@ impl Repository {
continue; continue;
} }
files.push(ChangedFile { files.push(ChangedFile {
review_revisions: None,
stats: None,
path: path.clone(), path: path.clone(),
old_path: None, old_path: None,
status: if a.is_none() { status: if a.is_none() {
@@ -2822,21 +3009,36 @@ impl Repository {
Ok(self.comparison_catalog(prepared)) Ok(self.comparison_catalog(prepared))
} }
fn comparison_catalog(&self, prepared: &PreparedComparison) -> Comparison { fn comparison_catalog(&mut self, prepared: &PreparedComparison) -> Comparison {
let stats = if prepared.files.is_empty() {
Ok(Vec::new())
} else {
self.ensure_private()
.and_then(|()| self.comparison_stats(prepared))
};
let files = prepared let files = prepared
.files .files
.iter() .iter()
.map(|(path, old_path, status)| ChangedFile { .enumerate()
.map(|(index, (path, old_path, status))| ChangedFile {
review_revisions: Some([
review_file_revision(prepared.left.get(old_path.as_ref().unwrap_or(path))),
review_file_revision(prepared.right.get(path)),
]),
path: path.clone(), path: path.clone(),
old_path: old_path.clone(), old_path: old_path.clone(),
status: *status, status: *status,
loaded: false, loaded: false,
stats: stats.as_ref().ok().and_then(|stats| stats[index]),
before: vec![], before: vec![],
after: vec![], after: vec![],
before_eof: false, before_eof: false,
after_eof: false, after_eof: false,
hunks: vec![], hunks: vec![],
notice: None, notice: stats
.as_ref()
.err()
.map(|error| format!("Line statistics unavailable: {error}")),
}) })
.collect(); .collect();
let editable_paths = if prepared.editable_after { let editable_paths = if prepared.editable_after {
@@ -2856,6 +3058,7 @@ impl Repository {
}; };
Comparison { Comparison {
revisions: prepared.revisions.clone(), revisions: prepared.revisions.clone(),
blame: prepared.blame.clone(),
before: prepared.before.clone(), before: prepared.before.clone(),
after: prepared.after.clone(), after: prepared.after.clone(),
files, files,
@@ -2864,6 +3067,125 @@ impl Repository {
} }
} }
fn comparison_stats(
&self,
prepared: &PreparedComparison,
) -> Result<Vec<Option<(usize, usize)>>, String> {
// ponytail: unopened counts use Git's text/Myers rules; share the renderer's
// counter if exact binary-detection and pathological-diff parity is needed.
let store = &self.private.as_ref().unwrap().1;
let sides: Vec<_> = prepared
.files
.iter()
.map(|(path, old_path, _)| {
[
prepared.left.get(old_path.as_ref().unwrap_or(path)),
prepared.right.get(path),
]
})
.collect();
let objects: BTreeSet<_> = sides
.iter()
.flatten()
.flatten()
.filter(|file| file.projection.is_none())
.filter_map(|file| file.object.as_ref().map(|(_, id)| id.as_str()))
.collect();
let mut sizes = BTreeMap::new();
if !objects.is_empty() {
let mut input = Vec::new();
for id in objects {
writeln!(&mut input, "{id}").unwrap();
}
let mut command = git_command(store);
command.arg("cat-file").arg("--batch-check");
let output = text(checked(run(command, input, &self.cancelled)?)?)?;
for line in output.lines() {
let fields: Vec<_> = line.split(' ').collect();
if fields.len() != 3 || fields[1] != "blob" {
return Err("Invalid statistics blob metadata".into());
}
let size = fields[2]
.parse::<usize>()
.map_err(|_| "Invalid statistics blob size")?;
sizes.insert(fields[0].to_owned(), size);
}
}
let mut stats = vec![Some((0, 0)); sides.len()];
let mut trees = [Vec::new(), Vec::new()];
for (index, files) in sides.iter().enumerate() {
self.check_cancelled()?;
if files.iter().flatten().any(|file| {
file.projection.is_some()
|| file.object.as_ref().is_some_and(|(_, id)| {
sizes
.get(id)
.is_none_or(|size| *size > COMPARISON_FILE_LIMIT)
})
}) {
stats[index] = None;
continue;
}
for (side, file) in files.iter().enumerate() {
if let Some((_, id)) = file.and_then(|file| file.object.as_ref()) {
// Flat synthetic paths avoid attributes, quoting and rename detection.
// Normalize modes: the renderer compares link targets as ordinary text.
write!(&mut trees[side], "100644 blob {id}\t{index}\0").unwrap();
}
}
}
if trees.iter().all(Vec::is_empty) {
return Ok(stats);
}
let mut ids = Vec::new();
for tree in trees {
let mut command = git_command(store);
command.args(["mktree", "-z"]);
ids.push(text(checked(run(command, tree, &self.cancelled)?)?)?);
}
let mut command = git_command(store);
command
.args(["-c", "core.attributesFile=/dev/null"])
.args([
"diff-tree",
"--numstat",
"-z",
"--no-renames",
"--no-ext-diff",
"--no-textconv",
"--diff-algorithm=myers",
"--no-indent-heuristic",
])
.args(ids.iter().map(|id| id.trim()))
.env("GIT_ATTR_NOSYSTEM", "1")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null");
let output = checked(run(command, Vec::new(), &self.cancelled)?)?;
for record in output.split(|byte| *byte == 0).filter(|r| !r.is_empty()) {
let record = std::str::from_utf8(record).map_err(|_| "Invalid line statistics")?;
let fields: Vec<_> = record.split('\t').collect();
if fields.len() != 3 {
return Err("Invalid line statistics".into());
}
let index = fields[2]
.parse::<usize>()
.ok()
.filter(|index| *index < stats.len())
.ok_or("Invalid line statistics index")?;
stats[index] = Some(if fields[0] == "-" && fields[1] == "-" {
(0, 0)
} else {
(
fields[0].parse().map_err(|_| "Invalid added line count")?,
fields[1]
.parse()
.map_err(|_| "Invalid removed line count")?,
)
});
}
Ok(stats)
}
/// Prepare one catalog entry using its original comparison's frozen sources. /// Prepare one catalog entry using its original comparison's frozen sources.
pub fn load_file( pub fn load_file(
&mut self, &mut self,
@@ -2902,6 +3224,7 @@ impl Repository {
)?; )?;
file.status = *status; file.status = *status;
file.old_path = old_path.clone(); file.old_path = old_path.clone();
file.review_revisions = Some([review_file_revision(before), review_file_revision(after)]);
Ok(file) Ok(file)
} }
fn changed_file( fn changed_file(
@@ -2972,6 +3295,8 @@ impl Repository {
}; };
notices.dedup(); notices.dedup();
Ok(ChangedFile { Ok(ChangedFile {
review_revisions: None,
stats: None,
path: path.to_path_buf(), path: path.to_path_buf(),
old_path: None, old_path: None,
status, status,
@@ -2990,6 +3315,14 @@ impl Repository {
} }
} }
fn review_file_revision(file: Option<&FileData>) -> String {
// Git object IDs cover exact bytes (including binary data and EOF), without loading blobs.
format!(
"file:{}",
serde_json::to_string(&file.map(file_identity)).unwrap()
)
}
fn file_identity(file: &FileData) -> (&str, Option<&str>, &[u8], Option<&str>) { fn file_identity(file: &FileData) -> (&str, Option<&str>, &[u8], Option<&str>) {
( (
&file.mode, &file.mode,
+577 -47
View File
@@ -4,6 +4,7 @@ use std::{
cell::RefCell, cell::RefCell,
collections::BTreeSet, collections::BTreeSet,
ffi::OsStr, ffi::OsStr,
fmt::Write as _,
fs::{self, OpenOptions}, fs::{self, OpenOptions},
io::{Read, Write}, io::{Read, Write},
path::{Path, PathBuf}, path::{Path, PathBuf},
@@ -42,7 +43,7 @@ pub enum Verdict {
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Anchor { pub struct Anchor {
/// Frozen comparison context; `content` additionally captures any live buffer edits. /// Frozen repository context; source comments use the exact buffer's Git blob identity.
pub revision: String, pub revision: String,
pub path: PathBuf, pub path: PathBuf,
pub side: Side, pub side: Side,
@@ -59,21 +60,115 @@ impl Anchor {
self.start.row..=end self.start.row..=end
} }
pub fn matches_lines( pub fn excerpt(&self) -> &str {
let offset = |position: crate::plugin::TextPosition| {
let line = self.content.split('\n').nth(position.row).unwrap_or("");
self.content
.split('\n')
.take(position.row)
.map(|line| line.len() + 1)
.sum::<usize>()
+ line
.grapheme_indices(true)
.nth(position.column)
.map_or(line.len(), |(index, _)| index)
};
&self.content[offset(self.start)..offset(self.end)]
}
/// Map an unchanged selection through edits without changing its saved snapshot.
pub fn mapped_range(
&self, &self,
revision: &str, revision: &str,
path: &Path, path: &Path,
side: Side, side: Side,
lines: &[String], lines: &[String],
eof: bool, eof: bool,
) -> bool { ) -> Option<std::ops::Range<crate::plugin::TextPosition>> {
self.revision == revision use crate::plugin::TextPosition;
&& self.path == path if self.path != path
&& self.side == side || self.side != side
&& self.content.split('\n').eq(lines || (self.revision != revision
&& !(self.revision.starts_with("working:") && revision.starts_with("working:")))
{
return None;
}
if self.content.split('\n').eq(lines
.iter() .iter()
.map(String::as_str) .map(String::as_str)
.chain((eof || lines.is_empty()).then_some(""))) .chain((eof || lines.is_empty()).then_some("")))
{
return Some(self.start..self.end);
}
let mut content = lines.join("\n");
if eof && !lines.is_empty() {
content.push('\n');
}
// Split LF explicitly: editor columns count a retained CR separately.
fn tokens(text: &str) -> Vec<&str> {
text.split_inclusive('\n')
.flat_map(|line| {
line.strip_suffix('\n')
.unwrap_or(line)
.graphemes(true)
.chain(line.ends_with('\n').then_some("\n"))
})
.collect()
}
let before = tokens(&self.content);
let after = tokens(&content);
let index = |position: TextPosition| {
self.content
.split('\n')
.take(position.row)
.map(|line| line.graphemes(true).count() + 1)
.sum::<usize>()
+ position.column
};
let start = index(self.start);
let end = index(self.end);
if start == end {
return None;
}
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(10);
let (mut old, mut new) = (0, 0);
for op in similar::capture_diff_slices_deadline(
similar::Algorithm::Myers,
&before,
&after,
Some(deadline),
) {
let (tag, old_range, new_range) = op.as_tag_tuple();
let old_end = old + old_range.len();
if tag == similar::DiffTag::Equal && start >= old && end <= old_end {
// Use the unchanged context, not just potentially repeated selected text.
let byte_start: usize = before[..old].iter().map(|text| text.len()).sum();
let byte_len: usize = before[old..old_end].iter().map(|text| text.len()).sum();
let context = &self.content[byte_start..byte_start + byte_len];
if self.content.match_indices(context).take(2).count() != 1
|| content.match_indices(context).take(2).count() != 1
{
return None;
}
let mapped = new + start - old;
let position = |offset: usize| {
let mut position = TextPosition { row: 0, column: 0 };
for grapheme in &after[..offset] {
if *grapheme == "\n" {
position.row += 1;
position.column = 0;
} else {
position.column += 1;
}
}
position
};
return Some(position(mapped)..position(mapped + end - start));
}
old = old_end;
new += new_range.len();
}
None
} }
} }
@@ -86,14 +181,26 @@ 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 {
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>,
pub reviewed_files: Vec<ReviewedFile>,
#[serde(skip)] #[serde(skip)]
pub storage_id: Option<i64>, pub storage_id: Option<i64>,
#[serde(skip)] #[serde(skip)]
@@ -126,34 +233,129 @@ impl Review {
fs::canonicalize(&root).unwrap_or(root) fs::canonicalize(&root).unwrap_or(root)
}; };
Self { Self {
version: 3, version: 5,
root, root,
comparison: None,
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),
} }
} }
pub fn summary_text(&self) -> String {
let resolved = self
.comments
.iter()
.filter(|comment| comment.resolved)
.count();
let (scope, before, arrow, after) = match &self.comparison {
Some([before, after]) => ("comparison ", before.as_str(), " → ", after.as_str()),
None => ("source", "", "", ""),
};
format!(
"Review ID: {}{}{}\n Scope: {}{}{}{}\n Verdict: {}; {} unresolved, {} resolved ({} total)\n",
self.storage_id
.map_or_else(|| "null".into(), |id| id.to_string()),
if self.summary.is_empty() { "" } else { ": " },
self.summary,
scope,
before,
arrow,
after,
match self.verdict {
Some(Verdict::LooksGood) => "looks_good",
Some(Verdict::ChangesRequested) => "changes_requested",
None => "none",
},
self.comments.len() - resolved,
resolved,
self.comments.len(),
)
}
/// CLI show format, including the repository, summary, and selected comments.
pub fn format_text(&self, all: bool) -> String {
let mut text = format!(
"Repository: {}\n{}",
self.root.display(),
self.summary_text()
);
for comment in self
.comments
.iter()
.filter(|comment| all || !comment.resolved)
{
let anchor = &comment.anchor;
let excerpt = anchor.excerpt();
let language =
match crate::syntax::language_id(Some(&anchor.path), anchor.content.lines().next())
{
Some("honey") => "hon",
language => language.unwrap_or(""),
};
let fence_width = 3.max(
excerpt
.split(|ch| ch != '`')
.map(str::len)
.max()
.unwrap_or(0)
+ 1,
);
writeln!(
text,
"\nComment {} [{}] {} ({}) {}:{}–{}:{} @ {}",
comment.id,
if comment.resolved {
"resolved"
} else {
"unresolved"
},
anchor.path.display(),
match anchor.side {
Side::Before => "before",
Side::After => "after",
},
anchor.start.row,
anchor.start.column,
anchor.end.row,
anchor.end.column,
anchor.revision,
)
.unwrap();
write!(text, "{:`<fence_width$}{language}\n{excerpt}", "").unwrap();
if !excerpt.ends_with('\n') {
text.push('\n');
}
writeln!(text, "{:`<fence_width$}\n{}", "", comment.body).unwrap();
}
text
}
fn validate(&self) -> Result<(), String> { fn validate(&self) -> Result<(), String> {
if self.version != 3 { 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) {
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 +383,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 +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 != 3 { 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}"))
@@ -264,6 +483,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(),
@@ -281,12 +508,55 @@ fn directory_from(xdg: Option<&OsStr>, home: Option<&OsStr>) -> Result<PathBuf,
.ok_or_else(|| "Cannot locate review storage: HOME must be an absolute directory".into()) .ok_or_else(|| "Cannot locate review storage: HOME must be an absolute directory".into())
} }
// ponytail: whole-review JSON commits; normalize comment rows if large reviews make writes costly. const REVIEWS_SCHEMA: &str = "CREATE TABLE reviews (
const SCHEMA: &str = "CREATE TABLE reviews (
id INTEGER PRIMARY KEY CHECK (id > 0), id INTEGER PRIMARY KEY CHECK (id > 0),
root TEXT NOT NULL, root TEXT NOT NULL,
revision INTEGER NOT NULL CHECK (revision > 0), revision INTEGER NOT NULL CHECK (revision > 0),
document TEXT NOT NULL CHECK (length(CAST(document AS BLOB)) <= 67108864) summary TEXT NOT NULL,
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";
// SQLite integers are signed; decimal text preserves the model's full u64 comment IDs.
const COMMENTS_SCHEMA: &str = "CREATE TABLE comments (
review_id INTEGER NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
id TEXT NOT NULL CHECK (
length(id) BETWEEN 1 AND 20 AND id NOT GLOB '*[^0-9]*'
AND substr(id, 1, 1) BETWEEN '1' AND '9'
AND (length(id) < 20 OR id <= '18446744073709551615')
),
position INTEGER NOT NULL CHECK (position >= 0),
body TEXT NOT NULL,
resolved INTEGER NOT NULL CHECK (resolved IN (0, 1)),
anchor_revision TEXT NOT NULL CHECK (length(anchor_revision) > 0),
path TEXT NOT NULL,
side TEXT NOT NULL CHECK (side IN ('before', 'after')),
start_row INTEGER NOT NULL CHECK (start_row >= 0),
start_column INTEGER NOT NULL CHECK (start_column >= 0),
end_row INTEGER NOT NULL CHECK (end_row >= 0),
end_column INTEGER NOT NULL CHECK (end_column >= 0),
content TEXT NOT NULL,
PRIMARY KEY (review_id, id),
UNIQUE (review_id, position),
CHECK (end_row > start_row OR (end_row = start_row AND end_column >= start_column))
) 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"; ) STRICT";
pub struct Store { pub struct Store {
@@ -316,6 +586,9 @@ impl Store {
) )
.map_err(database_error)?; .map_err(database_error)?;
Self::check_schema(&connection)?; Self::check_schema(&connection)?;
connection
.pragma_update(None, "foreign_keys", true)
.map_err(database_error)?;
Ok(Some(Self { connection })) Ok(Some(Self { connection }))
} }
@@ -330,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 != 1 || schema != [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"
)); ));
@@ -378,6 +651,9 @@ impl Store {
| OpenFlags::SQLITE_OPEN_NOFOLLOW, | OpenFlags::SQLITE_OPEN_NOFOLLOW,
) )
.map_err(database_error)?; .map_err(database_error)?;
connection
.pragma_update(None, "foreign_keys", true)
.map_err(database_error)?;
connection connection
.busy_timeout(std::time::Duration::ZERO) .busy_timeout(std::time::Duration::ZERO)
.map_err(database_error)?; .map_err(database_error)?;
@@ -385,9 +661,17 @@ impl Store {
let transaction = connection let transaction = connection
.transaction_with_behavior(TransactionBehavior::Immediate) .transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(database_error)?; .map_err(database_error)?;
transaction.execute_batch(SCHEMA).map_err(database_error)?;
transaction transaction
.pragma_update(None, "user_version", 1) .execute_batch(REVIEWS_SCHEMA)
.map_err(database_error)?;
transaction
.execute_batch(COMMENTS_SCHEMA)
.map_err(database_error)?;
transaction
.execute_batch(REVIEWED_FILES_SCHEMA)
.map_err(database_error)?;
transaction
.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 {
@@ -407,17 +691,24 @@ impl Store {
let root_text = root let root_text = root
.to_str() .to_str()
.ok_or("Review repository root must be UTF-8")?; .ok_or("Review repository root must be UTF-8")?;
let mut statement = self let transaction = self
.connection .connection
.unchecked_transaction()
.map_err(database_error)?;
let reviews = {
let mut statement = transaction
.prepare( .prepare(
"SELECT id, root, revision, document 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)?;
let mut reviews = Vec::new(); let mut reviews = Vec::new();
while let Some(row) = rows.next().map_err(database_error)? { while let Some(row) = rows.next().map_err(database_error)? {
reviews.push(stored_review(row, &root)?); reviews.push(stored_review(&transaction, row, &root)?);
} }
reviews
};
transaction.commit().map_err(database_error)?;
Ok(reviews) Ok(reviews)
} }
@@ -426,9 +717,13 @@ impl Store {
let root_text = root let root_text = root
.to_str() .to_str()
.ok_or("Review repository root must be UTF-8")?; .ok_or("Review repository root must be UTF-8")?;
let mut statement = self let transaction = self
.connection .connection
.prepare("SELECT id, root, revision, document FROM reviews WHERE id = ?1 AND root = ?2") .unchecked_transaction()
.map_err(database_error)?;
let review = {
let mut statement = transaction
.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])
@@ -436,8 +731,14 @@ impl Store {
let row = rows let row = rows
.next() .next()
.map_err(database_error)? .map_err(database_error)?
.ok_or("Review does not exist in this repository")?; .ok_or_else(|| format!(
stored_review(row, &root) "Review ID {id} not found in {}.\nRun `vamp --review list` to find review IDs for this repository.",
root.display()
))?;
stored_review(&transaction, row, &root)?
};
transaction.commit().map_err(database_error)?;
Ok(review)
} }
pub fn save(&mut self, review: &mut Review) -> Result<(), String> { pub fn save(&mut self, review: &mut Review) -> Result<(), String> {
@@ -457,35 +758,135 @@ impl Store {
None if review.storage_revision == 0 => 1, None if review.storage_revision == 0 => 1,
_ => return Err("Invalid review storage identity".into()), _ => return Err("Invalid review storage identity".into()),
}; };
let bytes = serde_json::to_vec(review).map_err(|error| error.to_string())?; let mut size = review.root.as_os_str().len() + review.summary.len();
if bytes.len() > LIMIT { if let Some(revisions) = &review.comparison {
size += revisions[0].len() + revisions[1].len();
}
for comment in &review.comments {
size = size
.checked_add(
comment.body.len()
+ comment.anchor.revision.len()
+ comment.anchor.path.as_os_str().len()
+ comment.anchor.content.len()
+ 64,
)
.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 {
return Err("Review exceeds the 64 MiB limit".into()); return Err("Review exceeds the 64 MiB limit".into());
} }
let document = std::str::from_utf8(&bytes).map_err(|error| error.to_string())?; let verdict = match review.verdict {
Some(Verdict::LooksGood) => Some("looks_good"),
Some(Verdict::ChangesRequested) => Some("changes_requested"),
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, document = ?2 WHERE id = ?3 AND root = ?4 AND revision = ?5", "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, document, 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, document) VALUES (?1, ?2, ?3)", "INSERT INTO reviews (root, revision, summary, verdict, before_revision, after_revision) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![root, revision, document], params![root, revision, review.summary, verdict, before, after],
) )
.map_err(database_error)?; .map_err(database_error)?;
transaction.last_insert_rowid() transaction.last_insert_rowid()
}; };
// ponytail: replace child rows per save; diff rows if snapshot writes become costly.
transaction
.execute("DELETE FROM comments WHERE review_id = ?1", [id])
.map_err(database_error)?;
{
let mut insert = transaction.prepare(
"INSERT INTO comments (review_id, id, position, body, resolved, anchor_revision, path, side,
start_row, start_column, end_row, end_column, content)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
).map_err(database_error)?;
for (position, comment) in review.comments.iter().enumerate() {
let anchor = &comment.anchor;
insert
.execute(params![
id,
comment.id.to_string(),
sql_index(position)?,
comment.body,
comment.resolved,
anchor.revision,
anchor.path.to_str().unwrap(),
match anchor.side {
Side::Before => "before",
Side::After => "after",
},
sql_index(anchor.start.row)?,
sql_index(anchor.start.column)?,
sql_index(anchor.end.row)?,
sql_index(anchor.end.column)?,
anchor.content,
])
.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;
@@ -497,26 +898,155 @@ fn database_error(error: rusqlite::Error) -> String {
format!("Review database: {error}") format!("Review database: {error}")
} }
fn stored_review(row: &rusqlite::Row<'_>, root: &Path) -> Result<Review, String> { fn sql_index(value: usize) -> Result<i64, String> {
i64::try_from(value).map_err(|_| "Comment coordinate exceeds SQLite's integer range".into())
}
fn stored_index(row: &rusqlite::Row<'_>, index: usize) -> Result<usize, String> {
usize::try_from(row.get::<_, i64>(index).map_err(database_error)?)
.map_err(|_| "Stored comment coordinate exceeds this platform's range".into())
}
fn stored_review(
connection: &Connection,
row: &rusqlite::Row<'_>,
root: &Path,
) -> Result<Review, String> {
let id: i64 = row.get(0).map_err(database_error)?; let id: i64 = row.get(0).map_err(database_error)?;
let stored_root: String = row.get(1).map_err(database_error)?; let stored_root = row
.get_ref(1)
.map_err(database_error)?
.as_str()
.map_err(|error| error.to_string())?;
let revision: i64 = row.get(2).map_err(database_error)?; let revision: i64 = row.get(2).map_err(database_error)?;
if id <= 0 || revision <= 0 || Path::new(&stored_root) != root { if id <= 0 || revision <= 0 || Path::new(stored_root) != root {
return Err("Invalid review database identity".into()); return Err("Invalid review database identity".into());
} }
let document = row.get_ref(3).map_err(database_error)?; let summary = row
let bytes = document .get_ref(3)
.map_err(database_error)?
.as_str() .as_str()
.map_err(|error| format!("Invalid stored review: {error}"))? .map_err(|error| error.to_string())?;
.as_bytes(); let stored_bytes: i64 = connection
if bytes.len() > LIMIT { .query_row(
"SELECT COALESCE(SUM(size), 0) FROM (
SELECT length(CAST(body AS BLOB)) + length(CAST(anchor_revision AS BLOB))
+ 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],
|row| row.get(0),
)
.map_err(database_error)?;
let mut header_bytes = stored_root.len() + summary.len();
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::from_json(bytes)?; let mut review = Review {
review.validate()?; root: root.to_owned(),
if review.root != root { ..Review::default()
return Err("Stored review belongs to a different repository".into()); };
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
.get::<_, Option<String>>(4)
.map_err(database_error)?
.as_deref()
{
Some("looks_good") => Some(Verdict::LooksGood),
Some("changes_requested") => Some(Verdict::ChangesRequested),
None => None,
_ => return Err("Invalid stored review verdict".into()),
};
let mut statement = connection
.prepare(
"SELECT id, position, body, resolved, anchor_revision, path, side,
start_row, start_column, end_row, end_column, content
FROM comments 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, 1)? != review.comments.len() {
return Err("Invalid stored comment order".into());
} }
let side = match row.get::<_, String>(6).map_err(database_error)?.as_str() {
"before" => Side::Before,
"after" => Side::After,
_ => return Err("Invalid stored comment side".into()),
};
let resolved = match row.get::<_, i64>(3).map_err(database_error)? {
0 => false,
1 => true,
_ => return Err("Invalid stored comment resolution".into()),
};
review.comments.push(Comment {
id: row
.get::<_, String>(0)
.map_err(database_error)?
.parse::<u64>()
.map_err(|_| "Invalid stored comment ID")?,
body: row.get(2).map_err(database_error)?,
resolved,
anchor: Anchor {
revision: row.get(4).map_err(database_error)?,
path: PathBuf::from(row.get::<_, String>(5).map_err(database_error)?),
side,
start: crate::plugin::TextPosition {
row: stored_index(row, 7)?,
column: stored_index(row, 8)?,
},
end: crate::plugin::TextPosition {
row: stored_index(row, 9)?,
column: stored_index(row, 10)?,
},
content: row.get(11).map_err(database_error)?,
},
});
}
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.storage_id = Some(id); review.storage_id = Some(id);
review.storage_revision = revision; review.storage_revision = revision;
Ok(review) Ok(review)
+97 -76
View File
@@ -3,15 +3,20 @@ use std::{
ffi::{OsStr, OsString}, ffi::{OsStr, OsString},
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
use unicode_segmentation::UnicodeSegmentation;
use vamp::{ use vamp::{
repository, repository,
review::{Anchor, Review, Store}, review::{Review, Store},
ruby, ruby,
}; };
enum CommentAction {
Delete,
Resolve,
}
pub struct Request { pub struct Request {
id: Option<i64>, id: Option<i64>,
comment: Option<(CommentAction, u64)>,
repo: Option<PathBuf>, repo: Option<PathBuf>,
json: bool, json: bool,
all: bool, all: bool,
@@ -19,22 +24,54 @@ pub struct Request {
impl Request { impl Request {
pub fn parse(args: &mut impl Iterator<Item = OsString>) -> Result<Self, String> { pub fn parse(args: &mut impl Iterator<Item = OsString>) -> Result<Self, String> {
let command = args.next().ok_or("--review requires list or show ID")?; let usage = "--review requires list, show ID, delete REVIEW_ID COMMENT_ID, or resolve REVIEW_ID COMMENT_ID";
let command = args.next().ok_or(usage)?;
let id = if command == "list" { let id = if command == "list" {
None None
} else if command == "show" { } else if command == "show" || command == "delete" || command == "resolve" {
let id = args.next().ok_or("show requires a positive review ID")?; let error = if command == "show" {
"`show` requires a positive review ID.\nUsage: vamp --review show ID [--all]\n`--all` includes resolved comments; it does not select all reviews."
} else if command == "delete" {
"`delete` requires a positive review ID.\nUsage: vamp --review delete REVIEW_ID COMMENT_ID"
} else {
"`resolve` requires a positive review ID.\nUsage: vamp --review resolve REVIEW_ID COMMENT_ID"
};
let id = args.next().ok_or(error)?;
Some( Some(
id.to_str() id.to_str()
.and_then(|id| id.parse::<i64>().ok()) .and_then(|id| id.parse::<i64>().ok())
.filter(|id| *id > 0) .filter(|id| *id > 0)
.ok_or("show requires a positive review ID")?, .ok_or(error)?,
) )
} else { } else {
return Err("--review requires list or show ID".into()); return Err(usage.into());
};
let comment = if command == "delete" || command == "resolve" {
let error = if command == "delete" {
"delete requires a positive comment ID"
} else {
"resolve requires a positive comment ID"
};
let id = args.next().ok_or(error)?;
let id = id
.to_str()
.and_then(|id| id.parse::<u64>().ok())
.filter(|id| *id > 0)
.ok_or(error)?;
Some((
if command == "delete" {
CommentAction::Delete
} else {
CommentAction::Resolve
},
id,
))
} else {
None
}; };
Ok(Self { Ok(Self {
id, id,
comment,
repo: None, repo: None,
json: false, json: false,
all: false, all: false,
@@ -50,7 +87,7 @@ impl Request {
self.repo = Some(PathBuf::from(args.next().ok_or("--repo requires a path")?)); self.repo = Some(PathBuf::from(args.next().ok_or("--repo requires a path")?));
} else if arg == "--json" && !self.json { } else if arg == "--json" && !self.json {
self.json = true; self.json = true;
} else if arg == "--all" && self.id.is_some() && !self.all { } else if arg == "--all" && self.id.is_some() && self.comment.is_none() && !self.all {
self.all = true; self.all = true;
} else { } else {
return Err(format!( return Err(format!(
@@ -62,24 +99,6 @@ impl Request {
} }
} }
fn excerpt(anchor: &Anchor) -> String {
// Positions use extended graphemes, not bytes or Unicode scalar values.
let offset = |position: vamp::plugin::TextPosition| {
let line = anchor.content.split('\n').nth(position.row).unwrap_or("");
anchor
.content
.split('\n')
.take(position.row)
.map(|line| line.len() + 1)
.sum::<usize>()
+ line
.grapheme_indices(true)
.nth(position.column)
.map_or(line.len(), |(index, _)| index)
};
anchor.content[offset(anchor.start)..offset(anchor.end)].to_owned()
}
fn envelope(review: &Review, all: bool, comments: bool) -> Value { fn envelope(review: &Review, all: bool, comments: bool) -> Value {
let resolved = review let resolved = review
.comments .comments
@@ -90,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 },
@@ -99,7 +119,7 @@ fn envelope(review: &Review, all: bool, comments: bool) -> Value {
json!({"id": comment.id, "body": comment.body, "resolved": comment.resolved, json!({"id": comment.id, "body": comment.body, "resolved": comment.resolved,
"anchor": { "revision": comment.anchor.revision, "path": comment.anchor.path, "anchor": { "revision": comment.anchor.revision, "path": comment.anchor.path,
"side": comment.anchor.side, "start": comment.anchor.start, "end": comment.anchor.end }, "side": comment.anchor.side, "start": comment.anchor.start, "end": comment.anchor.end },
"excerpt": excerpt(&comment.anchor)}) "excerpt": comment.anchor.excerpt()})
}).collect(); }).collect();
} }
value value
@@ -114,81 +134,82 @@ pub fn run(request: &Request, config: Option<&Path>) -> Result<(), String> {
let cwd = std::env::current_dir().map_err(|error| error.to_string())?; let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
let root = repository::discover_root(request.repo.as_deref().unwrap_or(&cwd))?; let root = repository::discover_root(request.repo.as_deref().unwrap_or(&cwd))?;
let store = Store::open_read_only(&directory)?; let store = Store::open_read_only(&directory)?;
let output = if let Some(id) = request.id { let reviews = if let Some(id) = request.id {
let review = store let mut review = store
.as_ref() .as_ref()
.ok_or("Review does not exist in this repository")? .ok_or_else(|| format!(
"Review ID {id} not found in {}.\nRun `vamp --review list` to find review IDs for this repository.",
root.display()
))?
.load(&root, id)?; .load(&root, id)?;
envelope(&review, request.all, true) if let Some((action, comment_id)) = &request.comment {
let index = review
.comments
.iter()
.position(|comment| comment.id == *comment_id)
.ok_or_else(|| format!("Comment {comment_id} does not exist in review {id}"))?;
let changed = match action {
CommentAction::Delete => {
review.comments.remove(index);
true
}
CommentAction::Resolve => {
let comment = &mut review.comments[index];
let changed = !comment.resolved;
comment.resolved = true;
changed
}
};
if changed {
Store::open(&directory)?.save(&mut review)?;
}
}
vec![review]
} else { } else {
let reviews = match store { match store {
Some(store) => store Some(store) => store
.list(&root)? .list(&root)?
.into_iter() .into_iter()
.map(|review| envelope(&review, true, false)) .filter(|review| !review.comments.is_empty())
.collect::<Vec<_>>(), .collect(),
None => Vec::new(), None => Vec::new(),
}; }
json!({"repository": root, "reviews": reviews})
}; };
if request.json { if request.json {
let output = if request.id.is_some() {
envelope(&reviews[0], request.all, true)
} else {
let reviews = reviews
.iter()
.map(|review| envelope(review, true, false))
.collect::<Vec<_>>();
json!({"repository": root, "reviews": reviews})
};
println!( println!(
"{}", "{}",
serde_json::to_string_pretty(&output).map_err(|error| error.to_string())? serde_json::to_string_pretty(&output).map_err(|error| error.to_string())?
); );
} else if request.id.is_some() {
print!("{}", reviews[0].format_text(request.all));
} else { } else {
println!("Repository: {}", root.display()); println!("Repository: {}", root.display());
if let Some(reviews) = output["reviews"].as_array() {
if reviews.is_empty() { if reviews.is_empty() {
println!("No stored reviews."); println!("No stored reviews.");
} }
for review in reviews { for review in &reviews {
print_review(review); print!("{}", review.summary_text());
}
} else {
print_review(&output);
for comment in output["comments"].as_array().unwrap() {
let anchor = &comment["anchor"];
println!( println!(
"\nComment {} [{}] {} ({}) {}:{}–{}:{} @ {}", "\nShow comments: vamp --review show {}",
comment["id"], review.storage_id.unwrap()
if comment["resolved"] == true {
"resolved"
} else {
"unresolved"
},
anchor["path"].as_str().unwrap(),
anchor["side"].as_str().unwrap(),
anchor["start"]["row"],
anchor["start"]["column"],
anchor["end"]["row"],
anchor["end"]["column"],
anchor["revision"].as_str().unwrap()
);
println!(
"{}\n{}",
comment["excerpt"].as_str().unwrap(),
comment["body"].as_str().unwrap()
); );
} }
if !reviews.is_empty() {
println!("Include resolved comments: add --all");
} }
} }
Ok(()) Ok(())
} }
fn print_review(review: &Value) {
println!(
"Review {} (revision {}): {}\n Verdict: {}; {} unresolved, {} resolved ({} total)",
review["id"],
review["revision"],
review["summary"].as_str().unwrap(),
review["verdict"].as_str().unwrap_or("none"),
review["counts"]["unresolved"],
review["counts"]["resolved"],
review["counts"]["total"]
);
}
#[cfg(test)] #[cfg(test)]
#[path = "../tests/unit/review_cli.rs"] #[path = "../tests/unit/review_cli.rs"]
mod tests; mod tests;
+52
View File
@@ -1,6 +1,7 @@
use crate::chrome::{ChromeLine, PopupConfig}; use crate::chrome::{ChromeLine, PopupConfig};
use crate::editor::{Context, Effect, Feature}; use crate::editor::{Context, Effect, Feature};
use crate::plugin::{DocumentEvent, PluginEvent, PluginUpdate, UpdateEffect}; use crate::plugin::{DocumentEvent, PluginEvent, PluginUpdate, UpdateEffect};
use crate::pull_requests::{Preferences, ReviewFilter, SortSettings};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::io::{self, BufRead, BufReader, Write}; use std::io::{self, BufRead, BufReader, Write};
use std::num::{NonZeroU8, NonZeroUsize}; use std::num::{NonZeroU8, NonZeroUsize};
@@ -42,6 +43,7 @@ pub struct Config {
pub clipboard_mode: String, pub clipboard_mode: String,
pub git_blame: bool, pub git_blame: bool,
pub git_gutter: crate::gutter::GutterStyle, pub git_gutter: crate::gutter::GutterStyle,
pub(crate) cursor: crate::cursor::CursorConfig,
pub top_bar: ChromeLine, pub top_bar: ChromeLine,
pub bottom_bar: ChromeLine, pub bottom_bar: ChromeLine,
pub popup: PopupConfig, pub popup: PopupConfig,
@@ -58,6 +60,10 @@ pub struct Config {
pub diff_view_mode: crate::diff::ViewMode, pub diff_view_mode: crate::diff::ViewMode,
pub diff_split_orientation: crate::panes::SplitAxis, pub diff_split_orientation: crate::panes::SplitAxis,
pub diff_follow_working_copy: bool, pub diff_follow_working_copy: bool,
pub pull_request_review_requested_symbol: String,
pub pull_request_approved_symbol: String,
pub(crate) pull_request_sort: Option<SortSettings>,
pub(crate) pull_request_filters: Option<Vec<ReviewFilter>>,
pub resolve_history_layout: crate::editor::ResolverLayout, pub resolve_history_layout: crate::editor::ResolverLayout,
} }
@@ -96,6 +102,7 @@ impl Default for Config {
clipboard_mode: "system".into(), clipboard_mode: "system".into(),
git_blame: true, git_blame: true,
git_gutter: crate::gutter::GutterStyle::default(), git_gutter: crate::gutter::GutterStyle::default(),
cursor: crate::cursor::CursorConfig::default(),
top_bar: ChromeLine::top(), top_bar: ChromeLine::top(),
bottom_bar: ChromeLine::bottom(), bottom_bar: ChromeLine::bottom(),
popup: PopupConfig::default(), popup: PopupConfig::default(),
@@ -112,6 +119,10 @@ impl Default for Config {
diff_view_mode: crate::diff::ViewMode::Split, diff_view_mode: crate::diff::ViewMode::Split,
diff_split_orientation: crate::panes::SplitAxis::Vertical, diff_split_orientation: crate::panes::SplitAxis::Vertical,
diff_follow_working_copy: true, diff_follow_working_copy: true,
pull_request_review_requested_symbol: "🙏".into(),
pull_request_approved_symbol: "👍".into(),
pull_request_sort: None,
pull_request_filters: None,
resolve_history_layout: crate::editor::ResolverLayout::Columns, resolve_history_layout: crate::editor::ResolverLayout::Columns,
} }
} }
@@ -137,6 +148,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
clipboard_mode, clipboard_mode,
git_blame, git_blame,
git_gutter, git_gutter,
cursor,
top_bar, top_bar,
bottom_bar, bottom_bar,
popup, popup,
@@ -153,6 +165,10 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
diff_view_mode, diff_view_mode,
diff_split_orientation, diff_split_orientation,
diff_follow_working_copy, diff_follow_working_copy,
pull_request_review_requested_symbol,
pull_request_approved_symbol,
pull_request_sort,
pull_request_filters,
resolve_history_layout, resolve_history_layout,
.. ..
} = *response; } = *response;
@@ -179,6 +195,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.clipboard_mode = clipboard_mode; config.clipboard_mode = clipboard_mode;
config.git_blame = git_blame; config.git_blame = git_blame;
config.git_gutter = git_gutter; config.git_gutter = git_gutter;
config.cursor = cursor;
config.top_bar = top_bar; config.top_bar = top_bar;
config.bottom_bar = bottom_bar; config.bottom_bar = bottom_bar;
config.popup = popup; config.popup = popup;
@@ -195,6 +212,19 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.diff_view_mode = diff_view_mode; config.diff_view_mode = diff_view_mode;
config.diff_split_orientation = diff_split_orientation; config.diff_split_orientation = diff_split_orientation;
config.diff_follow_working_copy = diff_follow_working_copy; config.diff_follow_working_copy = diff_follow_working_copy;
config.pull_request_review_requested_symbol = pull_request_review_requested_symbol;
config.pull_request_approved_symbol = pull_request_approved_symbol;
let sort_configured = pull_request_sort.is_some();
let filters_configured = pull_request_filters.is_some();
let preferences = Preferences {
sort: pull_request_sort.unwrap_or_default(),
filters: pull_request_filters.unwrap_or_default(),
};
preferences
.validate()
.map_err(|error| format!("Ruby config: {error}"))?;
config.pull_request_sort = sort_configured.then_some(preferences.sort);
config.pull_request_filters = filters_configured.then_some(preferences.filters);
config.resolve_history_layout = resolve_history_layout; config.resolve_history_layout = resolve_history_layout;
Ok(config) Ok(config)
} }
@@ -532,6 +562,8 @@ struct ConfigFrame {
clipboard_mode: String, clipboard_mode: String,
git_blame: bool, git_blame: bool,
git_gutter: crate::gutter::GutterStyle, git_gutter: crate::gutter::GutterStyle,
#[serde(default)]
cursor: crate::cursor::CursorConfig,
#[serde(deserialize_with = "crate::chrome::top")] #[serde(deserialize_with = "crate::chrome::top")]
top_bar: ChromeLine, top_bar: ChromeLine,
#[serde(deserialize_with = "crate::chrome::bottom")] #[serde(deserialize_with = "crate::chrome::bottom")]
@@ -556,9 +588,29 @@ struct ConfigFrame {
diff_view_mode: crate::diff::ViewMode, diff_view_mode: crate::diff::ViewMode,
diff_split_orientation: crate::panes::SplitAxis, diff_split_orientation: crate::panes::SplitAxis,
diff_follow_working_copy: bool, diff_follow_working_copy: bool,
#[serde(deserialize_with = "pull_request_symbol")]
pull_request_review_requested_symbol: String,
#[serde(deserialize_with = "pull_request_symbol")]
pull_request_approved_symbol: String,
#[serde(default)]
pull_request_sort: Option<SortSettings>,
#[serde(default)]
pull_request_filters: Option<Vec<ReviewFilter>>,
resolve_history_layout: crate::editor::ResolverLayout, resolve_history_layout: crate::editor::ResolverLayout,
} }
fn pull_request_symbol<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<String, D::Error> {
let symbol = String::deserialize(deserializer)?;
if symbol.chars().any(char::is_control) {
return Err(serde::de::Error::custom(
"pull request symbols must not contain control characters",
));
}
Ok(symbol)
}
fn whitespace_characters<'de, D: serde::Deserializer<'de>>( fn whitespace_characters<'de, D: serde::Deserializer<'de>>(
deserializer: D, deserializer: D,
) -> Result<BTreeMap<char, char>, D::Error> { ) -> Result<BTreeMap<char, char>, D::Error> {
+108
View File
@@ -142,6 +142,7 @@ module Vamp
@clipboard_mode = "system" @clipboard_mode = "system"
@git_blame = true @git_blame = true
@git_gutter = "bar" @git_gutter = "bar"
@cursor = {}
@bar = BarConfig.new @bar = BarConfig.new
@popup = PopupConfig.new @popup = PopupConfig.new
@labels = {} @labels = {}
@@ -157,6 +158,10 @@ module Vamp
@diff_view_mode = "split" @diff_view_mode = "split"
@diff_split_orientation = "vertical" @diff_split_orientation = "vertical"
@diff_follow_working_copy = true @diff_follow_working_copy = true
@pull_request_review_requested_symbol = "🙏"
@pull_request_approved_symbol = "👍"
@pull_request_sort = nil
@pull_request_filters = nil
@resolve_history_layout = "columns" @resolve_history_layout = "columns"
end end
@@ -227,6 +232,32 @@ module Vamp
@git_gutter = style @git_gutter = style
end end
# @param options [Hash{String, Symbol => Boolean, String, Symbol}] mode_colors defaults to true; mode shapes default to block
# @return [Hash{String => Boolean, String}]
def cursor=(options)
raise TypeError, "cursor options must be a Hash" unless options.is_a?(Hash)
@cursor = options.each_with_object({}) do |(option, value), result|
option = option.to_s if option.is_a?(Symbol)
option = Vamp.string!(option, "cursor option")
raise ArgumentError, "duplicate cursor option: #{option}" if result.key?(option)
case option
when "mode_colors"
raise TypeError, "cursor mode_colors must be true or false" unless [true, false].include?(value)
when "normal", "insert", "command", "search", "visual"
value = value.to_s if value.is_a?(Symbol)
value = Vamp.string!(value, "cursor shape")
unless %w[block bar underline].include?(value)
raise ArgumentError, "cursor shape must be block, bar, or underline"
end
else
raise ArgumentError, "unknown cursor option: #{option}"
end
result[option] = value
end
end
# @param values [Hash{String, Symbol => String}] # @param values [Hash{String, Symbol => String}]
# @return [Hash] # @return [Hash]
def labels=(values) def labels=(values)
@@ -327,6 +358,72 @@ module Vamp
@diff_follow_working_copy = enabled @diff_follow_working_copy = enabled
end end
# @param symbol [String] empty to hide the review-request indicator
# @return [String]
def pull_request_review_requested_symbol=(symbol)
@pull_request_review_requested_symbol = pull_request_symbol!(symbol, "pull_request_review_requested_symbol")
end
# @param symbol [String] empty to hide the approval indicator
# @return [String]
def pull_request_approved_symbol=(symbol)
@pull_request_approved_symbol = pull_request_symbol!(symbol, "pull_request_approved_symbol")
end
# @param fields [Hash{String, Symbol => Hash}, nil] explicit ranks and sort directions
# @return [Hash, nil]
def pull_request_sort=(fields)
return @pull_request_sort = nil if fields.nil?
raise TypeError, "pull_request_sort must be a Hash or nil" unless fields.is_a?(Hash)
ranks = []
normalized = Vamp.chrome_hash!(fields).to_h do |field, options|
unless %w[requested updated_at approved created_at].include?(field)
raise ArgumentError, "unknown pull_request_sort field: #{field}"
end
raise TypeError, "pull_request_sort #{field} must be a Hash" unless options.is_a?(Hash)
options = Vamp.chrome_hash!(options)
unknown = options.keys - %w[precedence direction]
raise ArgumentError, "unknown pull_request_sort option: #{unknown.first}" unless unknown.empty?
rank = options["precedence"]
unless rank.nil? || (rank.is_a?(Integer) && rank.between?(1, 4))
raise ArgumentError, "pull_request_sort precedence must be between 1 and 4"
end
raise ArgumentError, "duplicate pull_request_sort precedence: #{rank}" if rank && ranks.include?(rank)
ranks << rank if rank
if options.key?("direction")
direction = options["direction"]
direction = direction.to_s if direction.is_a?(Symbol)
unless %w[asc desc].include?(direction)
raise ArgumentError, "pull_request_sort direction must be asc or desc"
end
options["direction"] = direction
end
[field, options]
end
@pull_request_sort = normalized
end
# @param filters [Array<String, Symbol>, nil] selected personal review buckets
# @return [Array<String>, nil]
def pull_request_filters=(filters)
return @pull_request_filters = nil if filters.nil?
raise TypeError, "pull_request_filters must be an Array or nil" unless filters.is_a?(Array)
normalized = filters.map do |filter|
filter = filter.to_s if filter.is_a?(Symbol)
unless %w[requested approved other].include?(filter)
raise ArgumentError, "unknown pull_request_filters value: #{filter.inspect}"
end
filter
end
unless normalized.uniq.length == normalized.length
raise ArgumentError, "duplicate pull_request_filters value"
end
@pull_request_filters = normalized
end
# @param layout [String] "columns" or "base_below" # @param layout [String] "columns" or "base_below"
# @return [String] # @return [String]
def resolve_history_layout=(layout) def resolve_history_layout=(layout)
@@ -371,6 +468,7 @@ module Vamp
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode, "file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
"git_blame" => @git_blame, "git_blame" => @git_blame,
"git_gutter" => @git_gutter, "git_gutter" => @git_gutter,
"cursor" => @cursor,
"scroll_distance" => @scroll_distance, "scroll_distance" => @scroll_distance,
"scroll_step_lines" => @scroll_step_lines, "scroll_step_lines" => @scroll_step_lines,
"highlight_trailing_whitespace" => @highlight_trailing_whitespace, "highlight_trailing_whitespace" => @highlight_trailing_whitespace,
@@ -382,10 +480,20 @@ module Vamp
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines, "diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
"diff_view_mode" => @diff_view_mode, "diff_split_orientation" => @diff_split_orientation, "diff_view_mode" => @diff_view_mode, "diff_split_orientation" => @diff_split_orientation,
"diff_follow_working_copy" => @diff_follow_working_copy, "diff_follow_working_copy" => @diff_follow_working_copy,
"pull_request_review_requested_symbol" => @pull_request_review_requested_symbol,
"pull_request_approved_symbol" => @pull_request_approved_symbol,
"pull_request_sort" => @pull_request_sort, "pull_request_filters" => @pull_request_filters,
"resolve_history_layout" => @resolve_history_layout } "resolve_history_layout" => @resolve_history_layout }
end end
private private
def pull_request_symbol!(symbol, label)
symbol = Vamp.string!(symbol, label)
raise ArgumentError, "#{label} must not contain control characters" if symbol.match?(/[[:cntrl:]]/)
symbol
end
# @param spaces [Integer, nil] # @param spaces [Integer, nil]
# @return [Integer, nil] # @return [Integer, nil]
def tab_spaces!(spaces) def tab_spaces!(spaces)
+133
View File
@@ -439,10 +439,18 @@ fn text_fits(lines: &[String], limit: usize) -> bool {
.is_some() .is_some()
} }
struct ShellCommand {
command: String,
worker: crate::shell::Worker,
started: Instant,
label: String,
}
/// Owns command eligibility and execution hosts together. Frontends can inspect /// Owns command eligibility and execution hosts together. Frontends can inspect
/// the editor, but cannot independently mutate its plugin registry. /// the editor, but cannot independently mutate its plugin registry.
pub struct EditorSession { pub struct EditorSession {
editor: Editor, editor: Editor,
pull_request_preferences_store: crate::pull_request_preferences::Store,
plugins: BTreeMap<String, RubyPlugin>, plugins: BTreeMap<String, RubyPlugin>,
documents: BTreeMap<usize, TrackedDocument>, documents: BTreeMap<usize, TrackedDocument>,
requests: BTreeMap<String, AsyncRequests>, requests: BTreeMap<String, AsyncRequests>,
@@ -468,6 +476,7 @@ pub struct EditorSession {
picker_source: Option<PathBuf>, picker_source: Option<PathBuf>,
battery: Option<crate::battery::Battery>, battery: Option<crate::battery::Battery>,
battery_enabled: bool, battery_enabled: bool,
shell: Vec<ShellCommand>,
last_file_refresh: Instant, last_file_refresh: Instant,
last_diff_refresh: Instant, last_diff_refresh: Instant,
pub(crate) diff_follow_working_copy: bool, pub(crate) diff_follow_working_copy: bool,
@@ -480,6 +489,20 @@ impl EditorSession {
file: Option<PathBuf>, file: Option<PathBuf>,
capabilities: Vec<String>, capabilities: Vec<String>,
config: Option<&Path>, config: Option<&Path>,
) -> io::Result<Self> {
let path = if cfg!(test) {
None
} else {
Some(crate::pull_request_preferences::default_path())
};
Self::open_with_preferences_path(file, capabilities, config, path)
}
pub(crate) fn open_with_preferences_path(
file: Option<PathBuf>,
capabilities: Vec<String>,
config: Option<&Path>,
preferences_path: Option<Result<PathBuf, String>>,
) -> io::Result<Self> { ) -> io::Result<Self> {
let mut editor = Editor::open(file, capabilities)?; let mut editor = Editor::open(file, capabilities)?;
let mut notices = Vec::new(); let mut notices = Vec::new();
@@ -493,6 +516,29 @@ impl EditorSession {
}), }),
None => Config::default(), None => Config::default(),
}; };
editor.cursor_config = config.cursor;
let (pull_request_preferences_store, mut preferences, notice) = match preferences_path {
Some(path) => crate::pull_request_preferences::Store::load(path),
None => (
crate::pull_request_preferences::Store::disabled(),
crate::pull_requests::Preferences::default(),
None,
),
};
if let Some(notice) = notice {
notices.push(notice);
}
editor.pull_request_configured = [
config.pull_request_sort.is_some(),
config.pull_request_filters.is_some(),
];
if let Some(sort) = config.pull_request_sort {
preferences.sort = sort;
}
if let Some(filters) = config.pull_request_filters {
preferences.filters = filters;
}
editor.pull_request_preferences = preferences;
editor.set_review_directory(match config.review_directory { editor.set_review_directory(match config.review_directory {
Some(directory) => directory, Some(directory) => directory,
None => crate::review::default_directory().map_err(io::Error::other)?, None => crate::review::default_directory().map_err(io::Error::other)?,
@@ -534,6 +580,9 @@ impl EditorSession {
editor.set_tab_spaces(config.tab_spaces, config.language_tab_spaces); editor.set_tab_spaces(config.tab_spaces, config.language_tab_spaces);
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines); editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation); editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
editor.diff_follow_working_copy = config.diff_follow_working_copy;
editor.pull_request_review_requested_symbol = config.pull_request_review_requested_symbol;
editor.pull_request_approved_symbol = config.pull_request_approved_symbol;
editor.resolve_history_layout = config.resolve_history_layout; editor.resolve_history_layout = config.resolve_history_layout;
if let Err(error) = if let Err(error) =
editor.set_chrome_config(config.top_bar, config.bottom_bar, config.labels) editor.set_chrome_config(config.top_bar, config.bottom_bar, config.labels)
@@ -570,6 +619,7 @@ impl EditorSession {
} }
let mut session = Self { let mut session = Self {
editor, editor,
pull_request_preferences_store,
plugins, plugins,
documents: BTreeMap::new(), documents: BTreeMap::new(),
requests: BTreeMap::new(), requests: BTreeMap::new(),
@@ -595,6 +645,7 @@ impl EditorSession {
picker_source: None, picker_source: None,
battery: None, battery: None,
battery_enabled: false, battery_enabled: false,
shell: Vec::new(),
last_file_refresh: Instant::now(), last_file_refresh: Instant::now(),
last_diff_refresh: Instant::now(), last_diff_refresh: Instant::now(),
diff_follow_working_copy: config.diff_follow_working_copy, diff_follow_working_copy: config.diff_follow_working_copy,
@@ -654,6 +705,7 @@ impl EditorSession {
} }
self.start_diff_request(); self.start_diff_request();
self.start_resolver_requests(); self.start_resolver_requests();
self.save_pull_request_preferences();
} }
pub fn execute(&mut self, command: &str) { pub fn execute(&mut self, command: &str) {
@@ -666,6 +718,20 @@ impl EditorSession {
} }
self.start_diff_request(); self.start_diff_request();
self.start_resolver_requests(); self.start_resolver_requests();
self.save_pull_request_preferences();
}
fn save_pull_request_preferences(&mut self) -> bool {
if !std::mem::take(&mut self.editor.pull_request_preferences_changed) {
return false;
}
if let Err(error) = self
.pull_request_preferences_store
.save(&self.editor.pull_request_preferences)
{
self.editor.set_message(error);
}
true
} }
fn start_resolver_requests(&mut self) { fn start_resolver_requests(&mut self) {
@@ -827,6 +893,25 @@ impl EditorSession {
fn dispatch(&mut self, invocation: Invocation) { fn dispatch(&mut self, invocation: Invocation) {
if invocation.plugin == "core" { if invocation.plugin == "core" {
if invocation.command == "shell" {
let command = invocation.args;
match crate::shell::Worker::start(
command.clone(),
self.editor.project_root().to_path_buf(),
) {
Ok(worker) => {
self.shell.push(ShellCommand {
command,
worker,
started: Instant::now(),
label: String::new(),
});
self.poll_shell_at(Instant::now());
}
Err(error) => self.editor.show_command_output(command, Err(error)),
}
return;
}
if !self.editor.document_actions_available() if !self.editor.document_actions_available()
|| (invocation.command == "lsp.completion" && self.editor.mutation_blocked()) || (invocation.command == "lsp.completion" && self.editor.mutation_blocked())
{ {
@@ -1662,6 +1747,7 @@ impl EditorSession {
self.blame_request_id, self.blame_request_id,
source.path.to_path_buf(), source.path.to_path_buf(),
self.editor.lines().join("\n"), self.editor.lines().join("\n"),
source.snapshot.as_deref().cloned(),
) )
.is_err() .is_err()
{ {
@@ -1750,6 +1836,51 @@ impl EditorSession {
changed changed
} }
fn poll_shell_at(&mut self, now: Instant) -> bool {
use unicode_segmentation::UnicodeSegmentation;
const FRAMES: [char; 4] = ['|', '/', '-', '\\'];
let mut changed = false;
self.shell.retain_mut(|running| {
if let Some(result) = running.worker.poll() {
self.editor
.show_command_output(std::mem::take(&mut running.command), result);
changed = true;
return false;
}
let elapsed = now.saturating_duration_since(running.started);
if elapsed >= Duration::from_millis(200) {
let frame = FRAMES[((elapsed.as_millis() / 100) % 4) as usize];
if !running.label.ends_with(frame) {
running.label.clear();
let mut graphemes = running.command.trim().graphemes(true);
running.label.extend(graphemes.by_ref().take(3));
if graphemes.next().is_some() {
running.label.push_str("...");
}
running.label.push(' ');
running.label.push(frame);
changed = true;
}
}
true
});
if changed {
let mut label = String::new();
for running in self
.shell
.iter()
.filter(|running| !running.label.is_empty())
{
if !label.is_empty() {
label.push_str(" ");
}
label.push_str(&running.label);
}
self.editor.set_shell_label(label);
}
changed
}
fn refresh_files_at(&mut self, now: Instant) -> bool { fn refresh_files_at(&mut self, now: Instant) -> bool {
if now.saturating_duration_since(self.last_file_refresh) < Duration::from_millis(250) { if now.saturating_duration_since(self.last_file_refresh) < Duration::from_millis(250) {
return false; return false;
@@ -1774,9 +1905,11 @@ impl EditorSession {
fn poll_at(&mut self, now: Instant) -> bool { fn poll_at(&mut self, now: Instant) -> bool {
let mut changed = self.refresh_files_at(now); let mut changed = self.refresh_files_at(now);
changed |= self.save_pull_request_preferences();
changed |= self.poll_gutter_at(now); changed |= self.poll_gutter_at(now);
changed |= self.synchronize_documents(); changed |= self.synchronize_documents();
changed |= self.poll_battery(); changed |= self.poll_battery();
changed |= self.poll_shell_at(now);
self.refresh_checkout_diffs_at(now); self.refresh_checkout_diffs_at(now);
changed |= self.poll_diff(); changed |= self.poll_diff();
changed |= self.poll_resolver(); changed |= self.poll_resolver();
+240
View File
@@ -0,0 +1,240 @@
#[cfg(unix)]
use std::{
ffi::OsString,
io::{self, Read},
os::{
fd::OwnedFd,
unix::{net::UnixStream, process::CommandExt},
},
process::{Child, Command, Stdio},
sync::mpsc::{self, RecvTimeoutError},
thread,
time::Duration,
};
use std::{
path::PathBuf,
sync::mpsc::{Receiver, Sender, TryRecvError},
thread::JoinHandle,
};
#[cfg(unix)]
const OUTPUT_LIMIT: usize = 4 * 1024 * 1024;
#[cfg(unix)]
const TRUNCATION_NOTE: &[u8] = b"\n[vamp: output truncated after 4 MiB]\n";
#[derive(Debug)]
pub(crate) struct Output {
pub(crate) code: Option<i32>,
pub(crate) stdout: Vec<u8>,
pub(crate) stderr: Vec<u8>,
}
pub(crate) struct Worker {
cancel: Sender<()>,
results: Receiver<Result<Output, String>>,
thread: Option<JoinHandle<()>>,
finished: bool,
}
impl Worker {
pub(crate) fn start(command: String, root: PathBuf) -> Result<Self, String> {
#[cfg(unix)]
{
let shell = std::env::var_os("SHELL")
.filter(|shell| !shell.is_empty())
.unwrap_or_else(|| "/bin/sh".into());
Self::start_with_shell(command, root, shell)
}
#[cfg(not(unix))]
{
let _ = (command, root);
Err("Shell commands require Unix process-group cancellation on this platform".into())
}
}
#[cfg(unix)]
fn start_with_shell(command: String, root: PathBuf, shell: OsString) -> Result<Self, String> {
let (cancel, cancelled) = mpsc::channel();
let (send, results) = mpsc::channel();
let thread = thread::Builder::new()
.name("vamp-shell".into())
.spawn(move || {
let _ = send.send(run(command, root, shell, cancelled));
})
.map_err(|error| format!("Cannot start shell worker: {error}"))?;
Ok(Self {
cancel,
results,
thread: Some(thread),
finished: false,
})
}
pub(crate) fn poll(&mut self) -> Option<Result<Output, String>> {
if self.finished {
return None;
}
let result = match self.results.try_recv() {
Ok(output) => output,
Err(TryRecvError::Empty) => return None,
Err(TryRecvError::Disconnected) => Err("Shell worker stopped unexpectedly".into()),
};
self.finished = true;
Some(result)
}
}
impl Drop for Worker {
fn drop(&mut self) {
let _ = self.cancel.send(());
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
#[cfg(unix)]
struct Process(Child);
#[cfg(unix)]
impl Drop for Process {
fn drop(&mut self) {
unsafe extern "C" {
fn kill(pid: i32, signal: i32) -> i32;
}
// The shell and its descendants share a private process group; killing
// only the leader would leave children holding the output descriptors.
unsafe { kill(-(self.0.id() as i32), 9) };
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[cfg(unix)]
struct Capture {
reader: UnixStream,
bytes: Vec<u8>,
truncated: bool,
eof: bool,
}
#[cfg(unix)]
impl Capture {
fn new() -> Result<(Self, Stdio), String> {
let (reader, writer) = UnixStream::pair()
.map_err(|error| format!("Cannot create shell output stream: {error}"))?;
reader
.set_nonblocking(true)
.map_err(|error| format!("Cannot configure shell output stream: {error}"))?;
Ok((
Self {
reader,
bytes: Vec::new(),
truncated: false,
eof: false,
},
Stdio::from(OwnedFd::from(writer)),
))
}
fn read(&mut self) -> io::Result<bool> {
if self.eof {
return Ok(false);
}
let mut chunk = [0; 16 * 1024];
match self.reader.read(&mut chunk) {
Ok(0) => {
self.eof = true;
Ok(false)
}
Ok(count) => {
let keep = count.min(OUTPUT_LIMIT.saturating_sub(self.bytes.len()));
self.bytes.extend_from_slice(&chunk[..keep]);
self.truncated |= keep != count;
Ok(true)
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(false),
Err(error) if error.kind() == io::ErrorKind::Interrupted => Ok(true),
Err(error) => Err(error),
}
}
fn finish(mut self) -> Vec<u8> {
if self.truncated {
self.bytes.extend_from_slice(TRUNCATION_NOTE);
}
self.bytes
}
}
#[cfg(unix)]
fn run(
command: String,
root: PathBuf,
shell: OsString,
cancelled: Receiver<()>,
) -> Result<Output, String> {
if cancelled.try_recv().is_ok() {
return Err("Shell command cancelled".into());
}
let (mut stdout, stdout_writer) = Capture::new()?;
let (mut stderr, stderr_writer) = Capture::new()?;
let child = Command::new(shell)
.arg("-c")
.arg(command)
.current_dir(root)
.stdin(Stdio::null())
.stdout(stdout_writer)
.stderr(stderr_writer)
.process_group(0)
.spawn()
.map_err(|error| format!("Cannot start shell command: {error}"))?;
let mut process = Some(Process(child));
let mut status = None;
loop {
if cancelled.try_recv().is_ok() {
return Err("Shell command cancelled".into());
}
// Read each stream once per turn so a busy writer cannot starve the
// other stream or cancellation. Overflow is still drained, not retained.
let out = stdout
.read()
.map_err(|error| format!("Cannot read shell stdout: {error}"))?;
let err = stderr
.read()
.map_err(|error| format!("Cannot read shell stderr: {error}"))?;
if let Some(child) = process.as_mut() {
status = child
.0
.try_wait()
.map_err(|error| format!("Cannot wait for shell command: {error}"))?;
if status.is_some() {
// Background jobs must not outlive the command. Killing the
// remaining group also closes inherited output descriptors.
drop(process.take());
}
}
if let Some(status) = status
&& stdout.eof
&& stderr.eof
{
return Ok(Output {
code: status.code(),
stdout: stdout.finish(),
stderr: stderr.finish(),
});
}
if !out && !err {
match cancelled.recv_timeout(Duration::from_millis(10)) {
Ok(()) | Err(RecvTimeoutError::Disconnected) => {
return Err("Shell command cancelled".into());
}
Err(RecvTimeoutError::Timeout) => {}
}
}
}
}
#[cfg(all(test, unix))]
#[path = "../tests/unit/shell.rs"]
mod tests;
+1
View File
@@ -13,6 +13,7 @@ use languages::LanguageSpec;
pub(crate) const RUST_ID: &str = "rust"; pub(crate) const RUST_ID: &str = "rust";
pub(crate) const RUBY_ID: &str = "ruby"; pub(crate) const RUBY_ID: &str = "ruby";
pub(crate) const PYTHON_ID: &str = "python"; pub(crate) const PYTHON_ID: &str = "python";
pub(crate) const TYPST_ID: &str = "typst";
// Full trees and their indexes are optional; editing remains available beyond these budgets. // Full trees and their indexes are optional; editing remains available beyond these budgets.
const MAX_SYNTAX_BYTES: usize = 1024 * 1024; const MAX_SYNTAX_BYTES: usize = 1024 * 1024;
+37 -1
View File
@@ -90,6 +90,32 @@ const CPP_INJECTIONS: &str = "
(raw_string_content) @injection.content) (raw_string_content) @injection.content)
"; ";
// The Typst crate exports its grammar but not queries. Use its AST nodes with
// Vamp's supported capture roles, and standard captures for raw-code injections.
const TYPST_HIGHLIGHTS: &str = r#"
(comment) @comment
(string) @string
(number) @number
[(bool) (none) (auto)] @keyword
[
"let" "if" "else" "while" "for" "in" "import" "as" "include"
"show" "set" "return" "break" "continue" "context" "and" "or" "not"
] @keyword
(call item: (ident) @function)
(call item: (field field: (ident) @function))
[
(heading) (emph) (strong) (url) (label) (ref) (symbol)
(raw_blck) (raw_span)
] @markup
(item "-" @markup.list)
(term ["/" ":"] @markup.list)
"#;
const TYPST_INJECTIONS: &str = "
(raw_blck
lang: (ident) @injection.language
(blob) @injection.content)
";
fn javascript_injections() -> &'static str { fn javascript_injections() -> &'static str {
// The trailing upstream hbs/@glimmer rule uses a nonstandard #offset! directive, // The trailing upstream hbs/@glimmer rule uses a nonstandard #offset! directive,
// not injection.* captures. Glimmer is outside the selected language set; // not injection.* captures. Glimmer is outside the selected language set;
@@ -118,7 +144,7 @@ fn spec(
} }
} }
static LANGUAGES: LazyLock<[LanguageSpec; 20]> = LazyLock::new(|| { static LANGUAGES: LazyLock<[LanguageSpec; 21]> = LazyLock::new(|| {
[ [
spec( spec(
super::RUST_ID, super::RUST_ID,
@@ -280,6 +306,14 @@ static LANGUAGES: LazyLock<[LanguageSpec; 20]> = LazyLock::new(|| {
"", "",
"", "",
), ),
spec(
super::TYPST_ID,
"Typst",
tree_sitter_typst::LANGUAGE.into(),
TYPST_HIGHLIGHTS,
TYPST_INJECTIONS,
"",
),
] ]
}); });
@@ -320,6 +354,7 @@ pub(super) fn by_name(name: &str) -> Option<&'static LanguageSpec> {
("golang", "Go"), ("golang", "Go"),
("hon", "Honey"), ("hon", "Honey"),
("honeylang", "Honey"), ("honeylang", "Honey"),
("typ", "Typst"),
]; ];
let name = ALIASES let name = ALIASES
.iter() .iter()
@@ -376,6 +411,7 @@ fn detect_path(path: &Path) -> Option<&'static LanguageSpec> {
"go" => "Go", "go" => "Go",
"zig" => "Zig", "zig" => "Zig",
"hon" => "Honey", "hon" => "Honey",
"typ" => "Typst",
_ => return None, _ => return None,
} }
} }
+700 -141
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -629,10 +629,6 @@ fn branch_changes_default_and_mode_switches_compare_the_selected_divergent_tips(
session.editor().diff().unwrap().comparison_mode, session.editor().diff().unwrap().comparison_mode,
ComparisonMode::Snapshots ComparisonMode::Snapshots
); );
assert_eq!(
session.editor().message(),
"Working checkout requires Snapshots"
);
} }
#[test] #[test]
+187 -1
View File
@@ -226,6 +226,13 @@ fn comparison_revisions_freeze_commits_and_address_working_contents() {
.unwrap(); .unwrap();
assert_eq!(first.revisions[0], id); assert_eq!(first.revisions[0], id);
assert!(first.revisions[1].starts_with("working:")); assert!(first.revisions[1].starts_with("working:"));
assert_eq!(first.blame[0].as_ref().unwrap().commit, id);
assert_eq!(
first.blame[1]
.as_ref()
.map(|baseline| baseline.commit.as_str()),
(kind == RepoKind::Git).then_some(id.as_str())
);
let reopened = open(&root) let reopened = open(&root)
.compare(2, &before, &after, ComparisonMode::Snapshots, &[]) .compare(2, &before, &after, ComparisonMode::Snapshots, &[])
.unwrap(); .unwrap();
@@ -255,6 +262,10 @@ fn comparison_revisions_freeze_commits_and_address_working_contents() {
.unwrap(); .unwrap();
assert_eq!(working_pair.revisions[0], first.revisions[1]); assert_eq!(working_pair.revisions[0], first.revisions[1]);
assert_eq!(working_pair.revisions[0], working_pair.revisions[1]); assert_eq!(working_pair.revisions[0], working_pair.revisions[1]);
assert_eq!(
working_pair.blame,
[first.blame[1].clone(), first.blame[1].clone()]
);
assert_eq!(disk_state(&root), state); assert_eq!(disk_state(&root), state);
fs::write(root.join("file"), "working").unwrap(); fs::write(root.join("file"), "working").unwrap();
let no_newline = repository let no_newline = repository
@@ -264,6 +275,93 @@ fn comparison_revisions_freeze_commits_and_address_working_contents() {
} }
} }
#[test]
fn unopened_catalog_counts_frozen_text_but_not_binary_modes_or_exact_renames() {
let fixture = Fixture::new();
let root = fixture.dir("stats");
init_git(&root);
for (path, bytes) in [
("first", &b"old\nkeep\n"[..]),
("second", b"remove\nkeep\n"),
("removed", b"gone\n"),
("rename-old", b"move\n"),
("binary", b"\0old"),
("mode", b"script\n"),
("crlf", b"a\r\n"),
("eof", b"same\n"),
] {
fs::write(root.join(path), bytes).unwrap();
}
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "before"]);
let before = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git);
for (path, bytes) in [
("first", &b"new\nextra\nkeep\n"[..]),
("second", b"keep\n"),
("added", b"one\nlast"),
("binary", b"\0new"),
("crlf", b"b\r\n"),
("eof", b"same"),
] {
fs::write(root.join(path), bytes).unwrap();
}
fs::remove_file(root.join("removed")).unwrap();
fs::rename(root.join("rename-old"), root.join("-renamed\t\n")).unwrap();
fs::set_permissions(root.join("mode"), fs::Permissions::from_mode(0o755)).unwrap();
let mut repository = open(&root);
let catalog = repository
.compare(
1,
&before,
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert!(catalog.files.iter().all(|file| !file.loaded
&& file.before.is_empty()
&& file.after.is_empty()
&& file.hunks.is_empty()));
for (path, counts) in [
("first", (2, 1)),
("second", (0, 1)),
("added", (2, 0)),
("removed", (0, 1)),
("-renamed\t\n", (0, 0)),
("binary", (0, 0)),
("mode", (0, 0)),
("crlf", (1, 1)),
("eof", (1, 1)),
] {
assert_eq!(
changed(&catalog, Path::new(path)).stats,
Some(counts),
"{path}"
);
}
assert_eq!(
changed(&catalog, Path::new("-renamed\t\n")).status,
FileStatus::Renamed
);
fs::write(root.join("second"), "changed after freezing\n").unwrap();
for (index, file) in catalog.files.iter().enumerate() {
let loaded = repository.load_file(1, index).unwrap();
let counts = loaded.hunks.iter().fold((0, 0), |(added, removed), hunk| {
(added + hunk.after_len, removed + hunk.before_len)
});
assert_eq!(file.stats, Some(counts), "{}", file.path.display());
}
git(&root, &["add", "-A"]);
git(&root, &["commit", "-m", "advance checkout"]);
let refreshed = repository
.refresh_checkout(1, 2, true, &[])
.unwrap()
.map(checkout_comparison)
.unwrap();
assert_eq!(changed(&refreshed, Path::new("first")).stats, Some((2, 1)));
assert_eq!(changed(&refreshed, Path::new("second")).stats, Some((1, 2)));
}
#[test] #[test]
fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_paths() { fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_paths() {
let fixture = Fixture::new(); let fixture = Fixture::new();
@@ -785,6 +883,8 @@ fn jj_bookmark_refresh_tracks_movement_and_same_change_rewrites_without_touching
panic!("Bookmark movement must replace contents even when follow is disabled"); panic!("Bookmark movement must replace contents even when follow is disabled");
}; };
assert_eq!(comparison.after, topic.label); assert_eq!(comparison.after, topic.label);
assert!(comparison.blame[0].is_none());
assert_eq!(comparison.blame[1].as_ref().unwrap().commit, next);
assert_eq!(repository.load_file(2, 0).unwrap().after, ["next"]); assert_eq!(repository.load_file(2, 0).unwrap().after, ["next"]);
assert!(repository.load_file(1, 0).is_err()); assert!(repository.load_file(1, 0).is_err());
assert!( assert!(
@@ -944,6 +1044,8 @@ fn jj_bookmark_refresh_recomputes_branch_merge_base_and_recovers_after_deletion(
.unwrap(); .unwrap();
assert!(original.before.ends_with(&base)); assert!(original.before.ends_with(&base));
assert_eq!(original.revisions, [base.clone(), tip.clone()]); assert_eq!(original.revisions, [base.clone(), tip.clone()]);
assert_eq!(original.blame[0].as_ref().unwrap().commit, base);
assert_eq!(original.blame[1].as_ref().unwrap().commit, tip);
jj(&root, &["bookmark", "set", "before", "-r", &tip]); jj(&root, &["bookmark", "set", "before", "-r", &tip]);
let Some(CheckoutRefresh::Bookmark(comparison)) = let Some(CheckoutRefresh::Bookmark(comparison)) =
repository.refresh_checkout(1, 2, false, &[]).unwrap() repository.refresh_checkout(1, 2, false, &[]).unwrap()
@@ -952,6 +1054,8 @@ fn jj_bookmark_refresh_recomputes_branch_merge_base_and_recovers_after_deletion(
}; };
assert!(comparison.before.ends_with(&tip)); assert!(comparison.before.ends_with(&tip));
assert_eq!(comparison.revisions, [tip.clone(), tip.clone()]); assert_eq!(comparison.revisions, [tip.clone(), tip.clone()]);
assert_eq!(comparison.blame[0].as_ref().unwrap().commit, tip);
assert_eq!(comparison.blame[1].as_ref().unwrap().commit, tip);
assert!(comparison.files.is_empty()); assert!(comparison.files.is_empty());
jj(&root, &["bookmark", "delete", "after"]); jj(&root, &["bookmark", "delete", "after"]);
let state = disk_state(&root); let state = disk_state(&root);
@@ -1263,6 +1367,31 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
); );
assert_eq!(changed(&comparison, Path::new("file")).after, ["local"]); assert_eq!(changed(&comparison, Path::new("file")).after, ["local"]);
let loaded = repository.fetch_remote("origin").unwrap(); let loaded = repository.fetch_remote("origin").unwrap();
assert_eq!(loaded.graph[&tip].parents, [ancestor.clone()]);
assert_eq!(
loaded.graph[&ancestor].description,
"remote historic needle"
);
assert!(!loaded.graph[&tip].current);
let positions: std::collections::BTreeMap<_, _> = loaded
.entries
.iter()
.filter(|entry| entry.category == Category::Revisions)
.enumerate()
.filter_map(|(index, entry)| match &entry.target {
RevisionTarget::Commit(id) => Some((id.as_str(), index)),
_ => None,
})
.collect();
for (id, metadata) in &loaded.graph {
for parent in &metadata.parents {
if let (Some(child), Some(parent)) =
(positions.get(id.as_str()), positions.get(parent.as_str()))
{
assert!(child < parent);
}
}
}
let historic = loaded let historic = loaded
.entries .entries
.iter() .iter()
@@ -1331,6 +1460,7 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
.unwrap(); .unwrap();
assert_eq!(promoted.before, branch.before); assert_eq!(promoted.before, branch.before);
assert_eq!(promoted.after, branch.after); assert_eq!(promoted.after, branch.after);
assert_eq!(promoted.blame, branch.blame);
assert!(promoted.editable_paths.contains(Path::new("file"))); assert!(promoted.editable_paths.contains(Path::new("file")));
assert_eq!(repository.load_file(62, 0).unwrap().after, ["remote tip"]); assert_eq!(repository.load_file(62, 0).unwrap().after, ["remote tip"]);
assert_eq!(disk_state(&active), checked_out_state); assert_eq!(disk_state(&active), checked_out_state);
@@ -1692,6 +1822,8 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable
&[], &[],
) )
.unwrap(); .unwrap();
assert_eq!(changed(&branch, Path::new("conflict")).stats, None);
assert_eq!(changed(&branch, Path::new("ordinary")).stats, Some((1, 1)));
let index = branch let index = branch
.files .files
.iter() .iter()
@@ -1985,6 +2117,8 @@ fn comparison_size_and_line_ceilings_are_lazy_and_explicit() {
.unwrap(); .unwrap();
assert_eq!(catalog.files.len(), 3); assert_eq!(catalog.files.len(), 3);
assert!(catalog.files.iter().all(|file| !file.loaded)); assert!(catalog.files.iter().all(|file| !file.loaded));
assert_eq!(changed(&catalog, Path::new("too-many-bytes")).stats, None);
assert_eq!(changed(&catalog, Path::new("selected")).stats, Some((1, 0)));
for (index, entry) in catalog.files.iter().enumerate() { for (index, entry) in catalog.files.iter().enumerate() {
match entry.path.to_str().unwrap() { match entry.path.to_str().unwrap() {
"selected" => assert_eq!( "selected" => assert_eq!(
@@ -2260,6 +2394,8 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
.unwrap(); .unwrap();
assert_eq!(refreshed.before, original.before); assert_eq!(refreshed.before, original.before);
assert_eq!(refreshed.after, original.after); assert_eq!(refreshed.after, original.after);
assert_eq!(refreshed.blame[0], original.blame[0]);
assert_eq!(refreshed.blame[1].as_ref().unwrap().commit, tip);
for (index, file) in refreshed.files.iter_mut().enumerate() { for (index, file) in refreshed.files.iter_mut().enumerate() {
*file = repository.load_file(2, index).unwrap(); *file = repository.load_file(2, index).unwrap();
} }
@@ -2526,13 +2662,14 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
jj(&root, &["new"]); jj(&root, &["new"]);
fs::write(root.join("file"), "original disk\n").unwrap(); fs::write(root.join("file"), "original disk\n").unwrap();
let mut repository = open(&root); let mut repository = open(&root);
compare_loaded( let initial = compare_loaded(
&mut repository, &mut repository,
&revision(&base, RepoKind::Jujutsu), &revision(&base, RepoKind::Jujutsu),
&working(RepoKind::Jujutsu), &working(RepoKind::Jujutsu),
&[], &[],
) )
.unwrap(); .unwrap();
assert_eq!(initial.blame[1].as_ref().unwrap().commit, base);
fs::write(root.join("file"), "same-change rewrite\n").unwrap(); fs::write(root.join("file"), "same-change rewrite\n").unwrap();
jj(&root, &["describe", "-m", "rewritten"]); jj(&root, &["describe", "-m", "rewritten"]);
let state = disk_state(&root); let state = disk_state(&root);
@@ -2545,6 +2682,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]); assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]);
assert_eq!(disk_state(&root), state); assert_eq!(disk_state(&root), state);
jj(&root, &["new"]); jj(&root, &["new"]);
let parent = jj(&root, &["log", "--no-graph", "-r", "@-", "-T", "commit_id"]);
fs::remove_file(root.join("removed")).unwrap(); fs::remove_file(root.join("removed")).unwrap();
fs::write(root.join("added"), "new file\n").unwrap(); fs::write(root.join("added"), "new file\n").unwrap();
let state = disk_state(&root); let state = disk_state(&root);
@@ -2553,6 +2691,8 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
.unwrap() .unwrap()
.map(checkout_comparison) .map(checkout_comparison)
.unwrap(); .unwrap();
assert_eq!(comparison.blame[0], initial.blame[0]);
assert_eq!(comparison.blame[1].as_ref().unwrap().commit, parent);
for (index, file) in comparison.files.iter_mut().enumerate() { for (index, file) in comparison.files.iter_mut().enumerate() {
*file = repository.load_file(2, index).unwrap(); *file = repository.load_file(2, index).unwrap();
} }
@@ -2727,12 +2867,16 @@ fn branch_changes_resolves_local_and_private_remote_ancestry_without_active_muta
assert_eq!(branch.files.len(), 1); assert_eq!(branch.files.len(), 1);
assert_eq!(branch.files[0].path, Path::new("remote-only")); assert_eq!(branch.files[0].path, Path::new("remote-only"));
assert!(branch.before.ends_with(&base)); assert!(branch.before.ends_with(&base));
assert_eq!(branch.blame[0].as_ref().unwrap().commit, base);
assert!(!branch.blame[1].as_ref().unwrap().store.starts_with(&active));
assert_eq!(repository.load_file(40, 0).unwrap().after, ["remote"]); assert_eq!(repository.load_file(40, 0).unwrap().after, ["remote"]);
let reverse = repository let reverse = repository
.compare(41, remote, &local, ComparisonMode::BranchChanges, &[]) .compare(41, remote, &local, ComparisonMode::BranchChanges, &[])
.unwrap(); .unwrap();
assert_eq!(reverse.files.len(), 1); assert_eq!(reverse.files.len(), 1);
assert_eq!(reverse.files[0].path, Path::new("local-only")); assert_eq!(reverse.files[0].path, Path::new("local-only"));
assert_eq!(reverse.blame[0].as_ref().unwrap().commit, base);
assert_eq!(reverse.blame[1].as_ref().unwrap().commit, local.label);
assert_eq!(repository.load_file(41, 0).unwrap().after, ["local"]); assert_eq!(repository.load_file(41, 0).unwrap().after, ["local"]);
assert!( assert!(
repository repository
@@ -2866,3 +3010,45 @@ fn pull_request_refs_fetch_pinned_fork_heads_privately_for_git_and_jj() {
assert_eq!(disk_state(&root), state); assert_eq!(disk_state(&root), state);
} }
} }
#[test]
fn jj_working_blame_captures_one_parent_and_never_selects_an_arbitrary_merge_parent() {
let fixture = Fixture::new();
let root = fixture.dir("blame-parents");
init_jj(&root, false);
fs::write(root.join("file"), "base\n").unwrap();
jj(&root, &["describe", "-m", "base"]);
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new"]);
let state = disk_state(&root);
let mut repository = open(&root);
let one_parent = repository
.compare(
1,
&working(RepoKind::Jujutsu),
&working(RepoKind::Jujutsu),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert_eq!(one_parent.blame[0].as_ref().unwrap().commit, base);
assert_eq!(one_parent.blame[0], one_parent.blame[1]);
assert_eq!(disk_state(&root), state);
jj(&root, &["new", "root()"]);
fs::write(root.join("other"), "sibling\n").unwrap();
jj(&root, &["describe", "-m", "sibling"]);
let sibling = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new", &base, &sibling]);
let state = disk_state(&root);
let merged = repository
.compare(
2,
&working(RepoKind::Jujutsu),
&working(RepoKind::Jujutsu),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert_eq!(merged.blame, [None, None]);
assert_eq!(disk_state(&root), state);
}
+341
View File
@@ -0,0 +1,341 @@
use std::{fs, path::PathBuf, process::Command};
use vamp::{
plugin::TextPosition,
review::{Anchor, Comment, Review, Side, Store, Verdict},
};
struct Directory(PathBuf);
impl Drop for Directory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn cli_deletes_only_the_requested_comment_and_preserves_failed_targets() {
let directory = Directory(
std::env::temp_dir().join(format!("vamp-review-cli-delete-{}", std::process::id())),
);
fs::create_dir(&directory.0).unwrap();
let root = directory.0.canonicalize().unwrap();
for name in ["repo", "other"] {
let output = Command::new("jj")
.args(["git", "init"])
.arg(root.join(name))
.env("JJ_CONFIG", "/dev/null")
.output()
.unwrap();
assert!(output.status.success(), "{:?}", output);
}
let config = root.join("config.rb");
fs::write(
&config,
"Vamp.configure { |config| config.review_directory = 'reviews' }",
)
.unwrap();
let storage = root.join("reviews");
let mut store = Store::open(&storage).unwrap();
let mut review = Review::new(root.join("repo"));
review.summary = "Keep summary".into();
review.verdict = Some(Verdict::ChangesRequested);
review.comments = [1, u64::MAX]
.into_iter()
.map(|id| Comment {
id,
body: format!("Feedback {id}"),
resolved: id == u64::MAX,
anchor: Anchor {
revision: "snapshot".into(),
path: "file.rs".into(),
side: Side::After,
start: TextPosition { row: 0, column: 0 },
end: TextPosition { row: 0, column: 1 },
content: "界\n".into(),
},
})
.collect();
store.save(&mut review).unwrap();
let mut other = Review::new(root.join("other"));
other.comments = review.comments.clone();
store.save(&mut other).unwrap();
let id = review.storage_id.unwrap();
let cli = |repo: &str, args: &[&str]| {
Command::new(env!("CARGO_BIN_EXE_vamp"))
.arg("--config")
.arg(&config)
.arg("--review")
.args(args)
.current_dir(root.join(repo))
.output()
.unwrap()
};
let id_text = id.to_string();
for (repo, args) in [
("repo", vec!["delete", "999", "1"]),
("repo", vec!["delete", &id_text, "2"]),
("other", vec!["delete", &id_text, "1"]),
] {
let output = cli(repo, &args);
assert!(!output.status.success(), "{:?}", output);
let loaded = store.load(&review.root, id).unwrap();
assert_eq!(loaded.storage_revision, review.storage_revision);
assert_eq!(loaded.comments, review.comments);
}
let output = cli(
"repo",
&["delete", &id_text, "18446744073709551615", "--json"],
);
assert!(output.status.success(), "{:?}", output);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["id"], id);
assert_eq!(
json["counts"],
serde_json::json!({"total": 1, "resolved": 0, "unresolved": 1})
);
let loaded = store.load(&review.root, id).unwrap();
assert_eq!(loaded.storage_revision, review.storage_revision + 1);
assert_eq!(loaded.comments, review.comments[..1]);
assert_eq!(loaded.summary, review.summary);
assert_eq!(loaded.verdict, review.verdict);
assert!(store.save(&mut review).is_err()); // Stale editors cannot resurrect the deletion.
let output = cli("repo", &["delete", &id_text, "18446744073709551615"]);
assert!(!output.status.success());
assert_eq!(
store.load(&review.root, id).unwrap().storage_revision,
loaded.storage_revision
);
let output = cli("repo", &["delete", &id_text, "1"]);
assert!(output.status.success(), "{:?}", output);
assert!(
String::from_utf8(output.stdout)
.unwrap()
.contains("0 total")
);
let empty = store.load(&review.root, id).unwrap();
assert!(empty.comments.is_empty());
assert_eq!(empty.storage_revision, loaded.storage_revision + 1);
assert_eq!(empty.summary, review.summary);
let output = cli("repo", &["list", "--json"]);
assert!(output.status.success(), "{:?}", output);
let list: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(list["reviews"], serde_json::json!([]));
let output = cli("repo", &["list"]);
assert!(output.status.success(), "{:?}", output);
assert!(
String::from_utf8(output.stdout)
.unwrap()
.contains("No stored reviews.")
);
assert_eq!(
store
.load(&other.root, other.storage_id.unwrap())
.unwrap()
.comments,
other.comments
);
drop(store);
fs::remove_dir_all(&storage).unwrap();
let output = cli("repo", &["delete", &id_text, "1"]);
assert!(!output.status.success());
assert!(!storage.exists());
}
#[test]
fn cli_resolves_only_the_requested_comment_and_preserves_original_feedback() {
let directory = Directory(
std::env::temp_dir().join(format!("vamp-review-cli-resolve-{}", std::process::id())),
);
fs::create_dir(&directory.0).unwrap();
let root = directory.0.canonicalize().unwrap();
for name in ["repo", "other"] {
let output = Command::new("jj")
.args(["git", "init"])
.arg(root.join(name))
.env("JJ_CONFIG", "/dev/null")
.output()
.unwrap();
assert!(output.status.success(), "{:?}", output);
}
let config = root.join("config.rb");
fs::write(
&config,
"Vamp.configure { |config| config.review_directory = 'reviews' }",
)
.unwrap();
let storage = root.join("reviews");
let mut store = Store::open(&storage).unwrap();
let mut review = Review::new(root.join("repo"));
review.comparison = Some(["frozen-base".into(), "working:frozen-tip".into()]);
review.summary = "Keep summary".into();
review.verdict = Some(Verdict::ChangesRequested);
review.comments = [u64::MAX, 2, 3]
.into_iter()
.map(|id| Comment {
id,
body: format!("Original feedback {id}\nKeep this history."),
resolved: id == 3,
anchor: Anchor {
revision: "original-snapshot".into(),
path: "old/file.rs".into(),
side: Side::Before,
start: TextPosition { row: 0, column: 1 },
end: TextPosition { row: 1, column: 1 },
content: "xe\u{301}👩‍💻\n界z\n".into(),
},
})
.collect();
store.save(&mut review).unwrap();
let mut other = Review::new(root.join("other"));
other.comments = review.comments.clone();
store.save(&mut other).unwrap();
let mut source = Review::new(review.root.clone());
source.summary = "Progress without comments".into();
source.verdict = Some(Verdict::LooksGood);
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_text = id.to_string();
let cli = |repo: &str, args: &[&str]| {
Command::new(env!("CARGO_BIN_EXE_vamp"))
.arg("--config")
.arg(&config)
.arg("--review")
.args(args)
.arg("--repo")
.arg(root.join(repo))
.current_dir(&root)
.output()
.unwrap()
};
let path = storage.join("reviews.sqlite3");
let before = fs::read(&path).unwrap();
for (repo, args) in [
("repo", vec!["resolve"]),
("repo", vec!["resolve", &id_text]),
("repo", vec!["resolve", "0", "1"]),
("repo", vec!["resolve", &id_text, "0"]),
("repo", vec!["resolve", "999", "1"]),
("repo", vec!["resolve", &id_text, "1"]),
("other", vec!["resolve", &id_text, "2"]),
("repo", vec!["resolve", &id_text, "2", "--all"]),
] {
let output = cli(repo, &args);
assert!(!output.status.success(), "{:?}", output);
assert_eq!(fs::read(&path).unwrap(), before);
}
let output = cli(
"repo",
&["resolve", &id_text, "18446744073709551615", "--json"],
);
assert!(output.status.success(), "{:?}", output);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["id"], id);
assert_eq!(json["revision"], review.storage_revision + 1);
assert_eq!(json["comparison"], serde_json::json!(review.comparison));
assert_eq!(
json["counts"],
serde_json::json!({"total": 3, "resolved": 2, "unresolved": 1})
);
assert_eq!(json["comments"].as_array().unwrap().len(), 1);
assert_eq!(json["comments"][0]["id"], 2);
let loaded = store.load(&review.root, id).unwrap();
let mut expected = review.comments.clone();
expected[0].resolved = true;
assert_eq!(loaded.comments, expected);
assert_eq!(loaded.summary, review.summary);
assert_eq!(loaded.verdict, review.verdict);
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_eq!(store.load(&review.root, id).unwrap().comments, expected);
let output = cli("repo", &["show", &id_text, "--all", "--json"]);
assert!(output.status.success(), "{:?}", output);
let all: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(all["comments"].as_array().unwrap().len(), 3);
let original = &all["comments"][0];
assert_eq!(original["id"], u64::MAX);
assert_eq!(original["body"], review.comments[0].body);
assert_eq!(original["resolved"], true);
assert_eq!(original["anchor"]["revision"], "original-snapshot");
assert_eq!(original["anchor"]["path"], "old/file.rs");
assert_eq!(
original["anchor"]["start"],
serde_json::json!({"row": 0, "column": 1})
);
assert_eq!(original["excerpt"], "e\u{301}👩‍💻\n界");
let output = cli("repo", &["show", &id_text, "--json"]);
assert!(output.status.success(), "{:?}", output);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap(),
json
);
let before = fs::read(&path).unwrap();
for comment in ["18446744073709551615", "3"] {
let output = cli("repo", &["resolve", &id_text, comment, "--json"]);
assert!(output.status.success(), "{:?}", output);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap(),
json
);
assert_eq!(fs::read(&path).unwrap(), before);
}
let output = cli("repo", &["list", "--json"]);
assert!(output.status.success(), "{:?}", output);
let list: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(
list["reviews"]
.as_array()
.unwrap()
.iter()
.map(|review| review["id"].as_i64().unwrap())
.collect::<Vec<_>>(),
vec![id]
);
assert_eq!(list["reviews"][0]["comparison"], json["comparison"]);
assert_eq!(list["reviews"][0]["counts"], json["counts"]);
let output = cli("repo", &["list"]);
assert!(output.status.success(), "{:?}", output);
let text = String::from_utf8(output.stdout).unwrap();
assert!(!text.contains("Progress without comments"));
assert!(text.contains("Scope: comparison frozen-base → working:frozen-tip"));
assert!(!text.contains("Scope: comparison working:frozen-tip → frozen-base"));
assert_eq!(fs::read(&path).unwrap(), before);
let output = cli(
"repo",
&["show", &source.storage_id.unwrap().to_string(), "--json"],
);
assert!(output.status.success(), "{:?}", output);
let shown: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(shown["summary"], source.summary);
assert_eq!(shown["verdict"], "looks_good");
let output = cli("repo", &["resolve", &id_text, "2"]);
assert!(output.status.success(), "{:?}", output);
let text = String::from_utf8(output.stdout).unwrap();
assert!(!text.contains("Original feedback"));
let loaded = store.load(&review.root, id).unwrap();
assert_eq!(loaded.storage_revision, review.storage_revision + 2);
assert!(loaded.comments.iter().all(|comment| comment.resolved));
let output = cli("repo", &["list", "--json"]);
assert!(output.status.success(), "{:?}", output);
let list: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(list["reviews"][0]["id"], id);
assert_eq!(
list["reviews"][0]["counts"],
serde_json::json!({"total": 3, "resolved": 3, "unresolved": 0})
);
assert_eq!(
store
.load(&other.root, other.storage_id.unwrap())
.unwrap()
.comments,
other.comments
);
drop(store);
fs::remove_dir_all(&storage).unwrap();
let output = cli("repo", &["resolve", &id_text, "2"]);
assert!(!output.status.success(), "{:?}", output);
assert!(!storage.exists());
}
+255 -2
View File
@@ -29,6 +29,26 @@ fn command(root: &Path, program: &str, args: &[&str]) -> String {
); );
String::from_utf8(output.stdout).unwrap().trim().into() String::from_utf8(output.stdout).unwrap().trim().into()
} }
fn disk_state(root: &Path) -> std::collections::BTreeMap<PathBuf, Vec<u8>> {
fn visit(root: &Path, path: &Path, state: &mut std::collections::BTreeMap<PathBuf, Vec<u8>>) {
for entry in fs::read_dir(path).unwrap() {
let path = entry.unwrap().path();
let metadata = fs::symlink_metadata(&path).unwrap();
if metadata.is_dir() {
visit(root, &path, state);
} else if metadata.is_file() {
state.insert(
path.strip_prefix(root).unwrap().to_path_buf(),
fs::read(path).unwrap(),
);
}
}
}
let mut state = std::collections::BTreeMap::new();
visit(root, root, &mut state);
state
}
fn fixture() -> (Temporary, PathBuf, String) { fn fixture() -> (Temporary, PathBuf, String) {
let temp = Temporary::new().unwrap(); let temp = Temporary::new().unwrap();
command(&temp.0, "git", &["init", "--quiet", "--template="]); command(&temp.0, "git", &["init", "--quiet", "--template="]);
@@ -150,6 +170,239 @@ fn missing_untracked_unborn_nonrepo_and_symlink_have_no_blame() {
None None
); );
} }
#[test]
fn historical_git_and_jj_blame_survives_renamed_and_deleted_paths() {
for is_jj in [false, true] {
let (temp, old_path, original) = fixture();
if is_jj {
command(&temp.0, "jj", &["git", "init", "--colocate", "."]);
command(&temp.0, "jj", &["new", &original]);
}
fs::write(&old_path, "historical\nsecond\n").unwrap();
let selected = if is_jj {
command(
&temp.0,
"jj",
&["describe", "-m", "Selected historical lines"],
);
let id = command(
&temp.0,
"jj",
&["log", "--no-graph", "-r", "@", "-T", "commit_id"],
);
command(&temp.0, "jj", &["new"]);
id
} else {
command(&temp.0, "git", &["add", "."]);
command(
&temp.0,
"git",
&["commit", "--quiet", "-m", "Selected historical lines"],
);
command(&temp.0, "git", &["rev-parse", "HEAD"])
};
let renamed = temp.0.join("renamed.txt");
fs::rename(&old_path, &renamed).unwrap();
let renamed_commit = if is_jj {
command(&temp.0, "jj", &["describe", "-m", "Rename"]);
let id = command(
&temp.0,
"jj",
&["log", "--no-graph", "-r", "@", "-T", "commit_id"],
);
command(&temp.0, "jj", &["new"]);
id
} else {
command(&temp.0, "git", &["add", "-A"]);
command(&temp.0, "git", &["commit", "--quiet", "-m", "Rename"]);
command(&temp.0, "git", &["rev-parse", "HEAD"])
};
fs::remove_file(&renamed).unwrap();
if is_jj {
command(&temp.0, "jj", &["describe", "-m", "Delete"]);
command(&temp.0, "jj", &["new"]);
} else {
command(&temp.0, "git", &["add", "-A"]);
command(&temp.0, "git", &["commit", "--quiet", "-m", "Delete"]);
}
let store = if is_jj {
crate::repository::store_for_jj(&temp.0).unwrap()
} else {
temp.0.join(".git")
};
let state = disk_state(&temp.0);
for (commit, path) in [
(
selected.clone(),
old_path.strip_prefix(&temp.0).unwrap().to_path_buf(),
),
(renamed_commit, PathBuf::from("renamed.txt")),
] {
let source = RevisionSource {
store: store.clone(),
commit,
path,
keepalive: None,
};
let mut worker = BlameWorker::start().unwrap();
worker
.request(
1,
old_path.clone(),
"historical\nsecond\n".into(),
Some(source),
)
.unwrap();
let started = Instant::now();
let rows = loop {
if let Some((id, result)) = worker.poll() {
assert_eq!(id, 1);
break result.unwrap().unwrap();
}
assert!(
started.elapsed() < Duration::from_secs(20),
"Historical blame stalled"
);
thread::yield_now();
};
assert_eq!(rows[0].as_ref().unwrap().commit, selected);
assert_eq!(
rows[0].as_ref().unwrap().summary,
"Selected historical lines"
);
assert!(!rows[0].as_ref().unwrap().uncommitted);
assert_eq!(rows[1].as_ref().unwrap().commit, original);
}
assert!(!old_path.exists() && !renamed.exists());
assert_eq!(disk_state(&temp.0), state);
}
}
#[test]
fn privately_fetched_git_and_noncolocated_jj_sources_keep_selected_attribution() {
use crate::diff::{Category, ComparisonMode, Revision, RevisionTarget};
let (remote, remote_path, original) = fixture();
fs::write(&remote_path, "private historical\nsecond\n").unwrap();
command(&remote.0, "git", &["add", "."]);
command(
&remote.0,
"git",
&["commit", "--quiet", "-m", "Private selected"],
);
let selected = command(&remote.0, "git", &["rev-parse", "HEAD"]);
let reference = command(&remote.0, "git", &["symbolic-ref", "HEAD"]);
for is_jj in [false, true] {
let (active, active_path, _) = fixture();
fs::remove_file(&active_path).unwrap();
if is_jj {
fs::remove_dir_all(active.0.join(".git")).unwrap();
command(&active.0, "jj", &["git", "init", "--no-colocate", "."]);
command(
&active.0,
"jj",
&["git", "remote", "add", "origin", remote.0.to_str().unwrap()],
);
} else {
command(
&active.0,
"git",
&["remote", "add", "origin", remote.0.to_str().unwrap()],
);
}
let revision = |id: &str| Revision {
target: RevisionTarget::RemoteCommit {
remote: "origin".into(),
reference: reference.clone(),
id: id.into(),
},
label: id.into(),
description: String::new(),
category: Category::Commits,
};
let state = disk_state(&active.0);
let mut repository =
crate::repository::Repository::open(&active.0, Arc::new(AtomicBool::new(false)))
.unwrap();
let comparison = repository
.compare(
1,
&revision(&original),
&revision(&selected),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
let baseline = comparison.blame[1].as_ref().unwrap();
assert_eq!(baseline.commit, selected);
assert!(!baseline.store.starts_with(&active.0));
let source = RevisionSource {
store: baseline.store.clone(),
commit: baseline.commit.clone(),
path: remote_path.strip_prefix(&remote.0).unwrap().to_path_buf(),
keepalive: baseline.keepalive.clone(),
};
let store = source.store.clone();
drop(comparison);
drop(repository);
assert!(
store.exists(),
"Snapshot provenance must retain private fetched objects"
);
let rows = read_revision(&source, "private historical\nsecond\n", &|| false)
.unwrap()
.unwrap();
assert_eq!(rows[0].as_ref().unwrap().commit, selected);
assert_eq!(rows[1].as_ref().unwrap().commit, original);
assert!(!rows[0].as_ref().unwrap().uncommitted);
assert_eq!(disk_state(&active.0), state);
drop(source);
assert!(
!store.exists(),
"Last snapshot release must remove private objects"
);
}
}
#[test]
fn revision_sources_reject_unsafe_paths_and_skip_missing_or_nonregular_baselines() {
let (temp, path, commit) = fixture();
let mut source = RevisionSource {
store: temp.0.join(".git"),
commit,
path: "not-in-baseline".into(),
keepalive: None,
};
assert_eq!(read_revision(&source, "new\n", &|| false).unwrap(), None);
for path in ["", "../file", "/outside", ".git/config", ".jj/repo"] {
source.path = path.into();
assert!(read_revision(&source, "new\n", &|| false).is_err());
}
std::os::unix::fs::symlink(&path, temp.0.join("link")).unwrap();
fs::create_dir(temp.0.join("nested")).unwrap();
fs::write(temp.0.join("nested/file"), "nested\n").unwrap();
command(&temp.0, "git", &["add", "link", "nested/file"]);
command(&temp.0, "git", &["commit", "--quiet", "-m", "Link"]);
source.commit = command(&temp.0, "git", &["rev-parse", "HEAD"]);
source.path = "link".into();
assert_eq!(read_revision(&source, "target\n", &|| false).unwrap(), None);
source.path = "nested".into();
assert_eq!(read_revision(&source, "nested\n", &|| false).unwrap(), None);
source.path = "nested/file".into();
let nested = read_revision(&source, "nested\n", &|| false)
.unwrap()
.unwrap();
assert_eq!(nested[0].as_ref().unwrap().commit, source.commit);
source.path = path.strip_prefix(&temp.0).unwrap().into();
assert_eq!(
read_revision(&source, "first\n", &|| true).unwrap_err(),
"Blame cancelled"
);
assert_eq!(
read_revision(&source, &"x".repeat(LIMIT + 1), &|| false).unwrap_err(),
"Blame input exceeds 16 MiB"
);
}
#[test] #[test]
fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() { fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() {
let temp = Temporary::new().unwrap(); let temp = Temporary::new().unwrap();
@@ -332,7 +585,7 @@ fn worker_keeps_only_latest_request_and_bounds_input() {
let mut worker = BlameWorker::start().unwrap(); let mut worker = BlameWorker::start().unwrap();
for id in 0..20 { for id in 0..20 {
worker worker
.request(id, path.clone(), "first\nsecond\n".into()) .request(id, path.clone(), "first\nsecond\n".into(), None)
.unwrap(); .unwrap();
} }
loop { loop {
@@ -351,7 +604,7 @@ fn worker_keeps_only_latest_request_and_bounds_input() {
} }
assert_eq!( assert_eq!(
worker worker
.request(20, path, "x".repeat(LIMIT + 1)) .request(20, path, "x".repeat(LIMIT + 1), None)
.unwrap_err() .unwrap_err()
.kind(), .kind(),
io::ErrorKind::InvalidInput io::ErrorKind::InvalidInput
+7 -3
View File
@@ -77,6 +77,8 @@ fn omitted_options_inherit_and_explicit_groups_replace_defaults() {
#[test] #[test]
fn both_positions_accept_all_builtins_and_custom_styles() { fn both_positions_accept_all_builtins_and_custom_styles() {
let defaults = ChromeLine::top();
assert_eq!(defaults.right, ["diff", "shell", "battery"]);
let names = [ let names = [
"tabs", "tabs",
"mode", "mode",
@@ -90,6 +92,7 @@ fn both_positions_accept_all_builtins_and_custom_styles() {
"lsp", "lsp",
"position", "position",
"battery", "battery",
"diff",
"workspace", "workspace",
]; ];
let options = serde_json::json!({ let options = serde_json::json!({
@@ -119,7 +122,7 @@ fn both_positions_accept_all_builtins_and_custom_styles() {
} }
#[test] #[test]
fn view_placeholder_is_valid_in_every_segment_and_bar() { fn comparison_placeholders_are_valid_in_every_segment_and_bar() {
for name in [ for name in [
"tabs", "tabs",
"mode", "mode",
@@ -133,11 +136,12 @@ fn view_placeholder_is_valid_in_every_segment_and_bar() {
"lsp", "lsp",
"position", "position",
"battery", "battery",
"diff",
"workspace", "workspace",
] { ] {
let options = serde_json::json!({ let options = serde_json::json!({
"left": [name], "left": [name],
(name): {"title": "{index}:{view}{name}:{path}{modified}"} (name): {"title": "{index}:{view}{name}{changes}:{path}{modified}"}
}); });
let lines: Lines = serde_json::from_value(serde_json::json!({ let lines: Lines = serde_json::from_value(serde_json::json!({
"top": options, "top": options,
@@ -147,7 +151,7 @@ fn view_placeholder_is_valid_in_every_segment_and_bar() {
for line in [&lines.top, &lines.bottom] { for line in [&lines.top, &lines.bottom] {
assert_eq!( assert_eq!(
line.segment(name).title, line.segment(name).title,
"{index}:{view}{name}:{path}{modified}" "{index}:{view}{name}{changes}:{path}{modified}"
); );
} }
} }
+26
View File
@@ -28,12 +28,38 @@ fn review_commands_preserve_filenames_and_validate_arguments() {
.review .review
.is_some() .is_some()
); );
assert!(
parse(&[
"--review",
"delete",
"1",
"2",
"--repo",
".",
"--json",
"--no-config"
])
.unwrap()
.unwrap()
.review
.is_some()
);
for args in [ for args in [
vec!["--review"], vec!["--review"],
vec!["--review", "show"], vec!["--review", "show"],
vec!["--review", "show", "0"], vec!["--review", "show", "0"],
vec!["--review", "show", "-1"], vec!["--review", "show", "-1"],
vec!["--review", "show", "abc"], vec!["--review", "show", "abc"],
vec!["--review", "delete"],
vec!["--review", "delete", "1"],
vec!["--review", "delete", "0", "1"],
vec!["--review", "delete", "9223372036854775808", "1"],
vec!["--review", "delete", "1", "0"],
vec!["--review", "delete", "1", "-1"],
vec!["--review", "delete", "1", "abc"],
vec!["--review", "delete", "1", "18446744073709551616"],
vec!["--review", "delete", "1", "2", "--all"],
vec!["--review", "delete", "1", "2", "3"],
vec!["--review", "list", "--all"], vec!["--review", "list", "--all"],
vec!["--review", "list", "--repo"], vec!["--review", "list", "--repo"],
vec!["--review", "list", "--repo", ".", "--repo", "."], vec!["--review", "list", "--repo", ".", "--repo", "."],
+919 -71
View File
File diff suppressed because it is too large Load Diff
+1223 -188
View File
File diff suppressed because it is too large Load Diff
+657 -16
View File
@@ -7,6 +7,627 @@ fn parse(url: &str, provider: Option<&str>) -> Result<Forge, String> {
}) })
} }
#[test]
fn github_personal_approvals_use_the_viewer_and_last_decisive_review() {
let forge = parse("https://github.com/owner/repo", None).unwrap();
let values: Vec<_> = (1..=8).map(|id| json!({"number": id})).collect();
let metadata = forge.metadata_with(&values, &mut |url, body| {
if url.ends_with("/user") {
assert_eq!(url, "https://api.github.com/user");
return Ok(json!({"login": "viewer"}));
}
let body: Value = serde_json::from_str(body.unwrap()).unwrap();
let query = body["query"].as_str().unwrap();
assert!(query.contains("author:\"viewer\""));
assert!(query.contains("states:[APPROVED,CHANGES_REQUESTED,DISMISSED],last:1"));
assert!(!query.contains("mutation"));
Ok(json!({"data":{"repository":{
"p1":{"additions":3,"deletions":4,"reviews":{"nodes":[{"state":"APPROVED","author":{"login":"VIEWER"}}]}},
"p2":{"reviews":{"nodes":[{"state":"CHANGES_REQUESTED","author":{"login":"viewer"}}]}},
"p3":{"reviews":{"nodes":[{"state":"DISMISSED","author":{"login":"viewer"}}]}},
"p4":{"reviews":null},
"p5":{"reviews":{"nodes":[{"state":"COMMENTED","author":{"login":"viewer"}}]}},
"p6":{"reviews":{"nodes":[{"state":"APPROVED","author":{"login":"someone-else"}}]}},
"p7":{"reviews":{"nodes":[{"state":"PENDING","author":{"login":"viewer"}}]}},
"p8":{"reviews":{"nodes":[{"state":"APPROVED","author":{"login":"viewer"}}]}}
}}}))
});
assert_eq!(
metadata
.iter()
.filter_map(|(id, details)| details.approved.then_some(*id))
.collect::<Vec<_>>(),
[1, 8]
);
assert_eq!(metadata[&1].size, Some((3, 4)));
assert_eq!(metadata[&8].size, None);
}
#[test]
fn gitlab_personal_approvals_filter_by_viewer_and_follow_connection_pages() {
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
let values = [json!({"iid":7}), json!({"iid":8}), json!({"iid":9})];
let metadata = forge.metadata_with(&values, &mut |url, body| {
if url.ends_with("/user") {
assert_eq!(url, "https://gitlab.com/api/v4/user");
return Ok(json!({"username":"viewer"}));
}
let body: Value = serde_json::from_str(body.unwrap()).unwrap();
let query = body["query"].as_str().unwrap();
assert!(query.contains("approvedBy:[\"viewer\"]"));
assert!(query.contains("iids:[\"7\",\"8\",\"9\"]"));
assert!(!query.contains("mutation"));
if query.contains("after:\"next-page\"") {
return Ok(json!({"data":{"project":{"approved":{
"nodes":[{"iid":"8"}],
"pageInfo":{"hasNextPage":false,"endCursor":null}
}}}}));
}
Ok(json!({"data":{"project":{
"p7":{"diffStatsSummary":{"additions":5,"deletions":6}},
"p8":{"diffStatsSummary":null},
"p9":{"diffStatsSummary":{"additions":0,"deletions":0}},
"approved":{
"nodes":[{"iid":"7"},{"iid":"999"}],
"pageInfo":{"hasNextPage":true,"endCursor":"next-page"}
}
}}}))
});
assert!(metadata[&7].approved && metadata[&8].approved);
assert!(!metadata[&9].approved);
assert!(!metadata.contains_key(&999));
assert_eq!(metadata[&7].size, Some((5, 6)));
assert_eq!(metadata[&8].size, None);
}
#[test]
fn gitea_personal_approvals_ignore_other_users_and_honor_later_decisions_and_failures() {
let forge = parse("https://codeberg.org/owner/repo", None).unwrap();
let own = |id, state: &str, dismissed| json!({"id":id,"user":{"id":42},"state":state,"dismissed":dismissed});
let approved = own(10, "APPROVED", false);
for (pages, expected) in [
(
vec![
Ok(json!([approved.clone()])),
Ok(json!([
own(30, "COMMENT", false),
own(31, "PENDING", false),
own(32, "REQUEST_REVIEW", false)
])),
Ok(json!([])),
],
true,
),
(
vec![
Ok(json!([approved.clone()])),
Ok(json!([own(20, "REQUEST_CHANGES", false)])),
Ok(json!([])),
],
false,
),
(
vec![
Ok(json!([approved.clone()])),
Ok(json!([own(20, "APPROVED", true)])),
Ok(json!([])),
],
false,
),
(
vec![
Ok(json!([{"id":10,"user":{"id":7},"state":"APPROVED","dismissed":false}])),
Ok(json!([])),
],
false,
),
(
vec![
Ok(json!([approved.clone()])),
Err("later review page failed".into()),
],
false,
),
(
vec![Ok(json!([{"id":10,"user":{"id":42},"state":"APPROVED"}]))],
false,
),
// Select by review identity, not response order; a later approval replaces a rejection.
(
vec![
Ok(json!([
own(20, "APPROVED", false),
own(10, "REQUEST_CHANGES", false)
])),
Ok(json!([])),
],
true,
),
] {
let mut pages = pages.into_iter();
let metadata = forge.metadata_with(
&[json!({"number":7,"additions":1,"deletions":2})],
&mut |url, body| {
assert!(body.is_none());
if url.ends_with("/user") {
assert_eq!(url, "https://codeberg.org/api/v1/user");
return Ok(json!({"id":42}));
}
assert!(url.starts_with(
"https://codeberg.org/api/v1/repos/owner/repo/pulls/7/reviews?limit=50&page="
));
pages.next().expect("unexpected review page")
},
);
assert_eq!(metadata[&7].approved, expected);
assert_eq!(metadata[&7].size, Some((1, 2)));
}
}
#[test]
fn unavailable_viewer_or_metadata_never_fabricates_personal_approval() {
for (url, provider) in [
("https://github.com/owner/repo", Provider::Github),
("https://gitlab.com/group/repo", Provider::Gitlab),
("https://codeberg.org/owner/repo", Provider::Gitea),
] {
let forge = parse(url, None).unwrap();
for unavailable_viewer in [true, false] {
let metadata = forge.metadata_with(
&[json!({"number":7,"iid":7,"additions":0,"deletions":0,
"requested_reviewers":[{"login":"viewer","id":42}],
"reviewers":[{"username":"viewer"}]})],
&mut |url, body| {
if url.ends_with("/user") {
return if unavailable_viewer {
Err("no API credentials".into())
} else {
Ok(json!({"login":"viewer","username":"viewer","id":42}))
};
}
if provider == Provider::Gitea {
assert!(
!unavailable_viewer,
"anonymous listings must not fetch reviews"
);
assert!(body.is_none());
}
Err("metadata denied".into())
},
);
assert!(metadata.values().all(|details| !details.approved));
assert!(metadata.values().all(|details| details.review_requested
== (provider == Provider::Github && !unavailable_viewer)));
if provider == Provider::Gitea {
assert_eq!(metadata[&7].size, Some((0, 0)));
}
}
}
}
#[test]
fn github_outstanding_requests_use_list_users_independently_of_review_metadata() {
let forge = parse("https://github.com/owner/repo", None).unwrap();
for (reviewers, expected) in [
(json!([{"login":"VIEWER"}]), true), // Direct or re-requested after approval.
(json!([]), false), // Completed or removed requests are absent.
(json!([{"login":"another-user"}]), false),
(Value::Null, false),
] {
for denied in [false, true] {
let metadata = forge.metadata_with(
&[json!({"number":7,"requested_reviewers":reviewers,
"requested_teams":[{"slug":"viewer"}],"additions":3,"deletions":4})],
&mut |url, body| {
if url.ends_with("/user") {
return Ok(json!({"login":"viewer"}));
}
assert!(body.is_some());
if denied {
return Err("GraphQL denied".into());
}
Ok(
json!({"data":{"repository":{"p7":{"additions":3,"deletions":4,
"reviews":{"nodes":[{"state":"APPROVED","author":{"login":"viewer"}}]}}}}}),
)
},
);
assert_eq!(metadata[&7].review_requested, expected);
assert_eq!(metadata[&7].approved, !denied);
assert_eq!(metadata[&7].size, Some((3, 4)));
}
}
}
#[test]
fn gitlab_outstanding_requests_require_personal_unfinished_review_and_follow_pages() {
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
let mut values: Vec<_> = (1..=10)
.map(|id| json!({"iid":id,"reviewers":[{"username":"viewer"}]}))
.collect();
values[8]["reviewers"] = json!([]); // Removed reviewer.
values[9]["reviewers"] = json!([{"username":"another-user"}]);
let mut request_reads = 0;
let metadata = forge.metadata_with(&values, &mut |url, body| {
if url.ends_with("/user") {
return Ok(json!({"username":"viewer"}));
}
let body: Value = serde_json::from_str(body.unwrap()).unwrap();
let query = body["query"].as_str().unwrap();
assert!(!query.contains("mutation"));
if !query.contains("reviewState") {
return Ok(json!({"data":{"project":{
"p1":{"diffStatsSummary":{"additions":5,"deletions":6}},
"approved":{"nodes":[{"iid":"1"},{"iid":"4"}],"pageInfo":{"hasNextPage":false}}
}}}));
}
request_reads += 1;
assert!(query.contains("reviewers(first:20"));
assert!(!query.contains("p9:") && !query.contains("p10:"));
if query.contains("after:\"next\"") {
return Ok(json!({"data":{"project":{"p2":{"reviewers":{
"nodes":[{"username":"viewer","mergeRequestInteraction":{"reviewState":"REVIEW_STARTED"}}],
"pageInfo":{"hasNextPage":false}
}}}}}));
}
let mut entries = serde_json::Map::new();
for (id, state) in [(1, "UNREVIEWED"), (3, "REVIEWED"), (4, "APPROVED"),
(5, "REQUESTED_CHANGES"), (6, "UNAPPROVED"), (7, ""), (8, "UNKNOWN")] {
entries.insert(format!("p{id}"), json!({"reviewers":{
"nodes":[{"username":"viewer","mergeRequestInteraction":{"reviewState":state}}],
"pageInfo":{"hasNextPage":false}
}}));
}
entries.insert("p2".into(), json!({"reviewers":{
"nodes":[{"username":"another-user","mergeRequestInteraction":{"reviewState":"UNREVIEWED"}}],
"pageInfo":{"hasNextPage":true,"endCursor":"next"}
}}));
Ok(json!({"data":{"project":entries}}))
});
assert_eq!(request_reads, 2);
assert_eq!(
metadata
.iter()
.filter_map(|(id, details)| details.review_requested.then_some(*id))
.collect::<Vec<_>>(),
[1, 2]
);
assert!(metadata[&1].approved && metadata[&4].approved);
assert_eq!(metadata[&1].size, Some((5, 6)));
}
#[test]
fn gitlab_request_failures_preserve_approval_and_statistics() {
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
for denied in [
Err("review states denied".into()),
Ok(json!({"data":{"project":{
"p7":{"reviewers":null}
}}})),
Ok(json!({"data":{"project":{"p7":{"reviewers":{"nodes":[
{"username":"viewer","mergeRequestInteraction":{"reviewState":null}}
]}}}}})),
] {
let metadata = forge.metadata_with(
&[json!({"iid":7,"reviewers":[{"username":"viewer"}]})],
&mut |url, body| {
if url.ends_with("/user") {
return Ok(json!({"username":"viewer"}));
}
let body: Value = serde_json::from_str(body.unwrap()).unwrap();
if body["query"].as_str().unwrap().contains("reviewState") {
return denied.clone();
}
Ok(json!({"data":{"project":{
"p7":{"diffStatsSummary":{"additions":0,"deletions":1}},
"approved":{"nodes":[{"iid":"7"}],"pageInfo":{"hasNextPage":false}}
}}}))
},
);
assert!(!metadata[&7].review_requested);
assert!(metadata[&7].approved);
assert_eq!(metadata[&7].size, Some((0, 1)));
}
}
#[test]
fn gitlab_request_batches_cover_fifty_rows_without_exceeding_complexity_limit() {
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
let values: Vec<_> = (1..=50)
.map(|id| {
json!({
"iid":id,"reviewers":[{"username":"viewer"}]
})
})
.collect();
let mut batches = 0;
let metadata = forge.metadata_with(&values, &mut |url, body| {
if url.ends_with("/user") {
return Ok(json!({"username":"viewer"}));
}
let body: Value = serde_json::from_str(body.unwrap()).unwrap();
let query = body["query"].as_str().unwrap();
if !query.contains("reviewState") {
return Err("independent metadata unavailable".into());
}
batches += 1;
assert_eq!(query.matches("reviewers(first:20)").count(), 10);
let entries: serde_json::Map<_, _> = (1..=50)
.filter(|id| query.contains(&format!("p{id}:mergeRequest")))
.map(|id| (format!("p{id}"), json!({"reviewers":{
"nodes":[{"username":"viewer","mergeRequestInteraction":{"reviewState":"UNREVIEWED"}}],
"pageInfo":{"hasNextPage":false}
}}))).collect();
Ok(json!({"data":{"project":entries}}))
});
assert_eq!(batches, 5);
assert_eq!(metadata.len(), 50);
assert!(metadata.values().all(|details| details.review_requested));
}
#[test]
fn gitea_requests_ignore_list_assignments_and_use_current_direct_review_rows() {
let forge = parse("https://codeberg.org/owner/repo", None).unwrap();
let own = |id, state: &str| {
json!({
"id":id,"user":{"id":42},"team":null,"state":state,"dismissed":false
})
};
for (reviews, requested, approved) in [
(json!([own(10, "REQUEST_REVIEW")]), true, false),
(json!([own(10, "APPROVED")]), false, true), // Completed or removed request row.
(json!([own(10, "COMMENT")]), false, false),
(json!([own(10, "REQUEST_CHANGES")]), false, false),
(
json!([own(10, "APPROVED"), own(20, "REQUEST_REVIEW")]),
true,
true,
),
(
json!([
own(20, "REQUEST_REVIEW"),
own(10, "APPROVED"),
own(30, "PENDING")
]),
true,
true,
),
(
json!([own(10, "REQUEST_REVIEW"), own(20, "COMMENT")]),
false,
false,
),
(
json!([own(10, "REQUEST_REVIEW"), own(20, "APPROVED")]),
false,
true,
),
(
json!([{"id":10,"user":{"id":7},"state":"REQUEST_REVIEW","dismissed":false}]),
false,
false,
),
(
json!([{"id":10,"user":{"id":42},"team":{"id":1},"state":"REQUEST_REVIEW","dismissed":false}]),
false,
false,
),
(
json!([{"id":20,"user":{"id":42},"state":"REQUEST_REVIEW"},
own(10, "APPROVED")]),
false,
true,
),
(json!([]), false, false),
] {
let mut pages = vec![reviews, json!([])].into_iter();
let metadata = forge.metadata_with(
&[json!({"number":7,"additions":1,"deletions":2,"requested_reviewers":[{"id":42}]})],
&mut |url, body| {
assert!(body.is_none());
if url.ends_with("/user") {
return Ok(json!({"id":42}));
}
pages.next().ok_or_else(|| "unexpected page".into())
},
);
assert_eq!(metadata[&7].review_requested, requested);
assert_eq!(metadata[&7].approved, approved);
assert_eq!(metadata[&7].size, Some((1, 2)));
}
}
#[test]
fn gitea_incomplete_request_pages_do_not_fabricate_requests_or_lose_list_statistics() {
let forge = parse("https://codeberg.org/owner/repo", None).unwrap();
let metadata = forge.metadata_with(
&[json!({"number":7,"additions":0,"deletions":2})],
&mut |url, _| {
if url.ends_with("/user") {
return Ok(json!({"id":42}));
}
if url.ends_with("page=1") {
return Ok(json!([{"id":10,"user":{"id":42},"team":null,
"state":"REQUEST_REVIEW","dismissed":false}]));
}
Err("remaining reviews denied".into())
},
);
assert!(!metadata[&7].review_requested);
assert_eq!(metadata[&7].size, Some((0, 2)));
}
#[test]
fn summaries_keep_outstanding_requests_and_approvals_independent() {
for (url, provider) in [
("https://github.com/owner/repo", Provider::Github),
("https://gitlab.com/owner/repo", Provider::Gitlab),
("https://codeberg.org/owner/repo", Provider::Gitea),
] {
let forge = parse(url, None).unwrap();
let page = forge.page_with(false, 1, |url, body| {
if url.ends_with("/user") {
return Ok(json!({"id":42,"login":"viewer","username":"viewer"}));
}
if let Some(body) = body {
let body: Value = serde_json::from_str(body).unwrap();
if body["query"].as_str().unwrap().contains("reviewState") {
return Ok(json!({"data":{"project":{"p7":{"reviewers":{
"nodes":[{"username":"viewer","mergeRequestInteraction":{"reviewState":"UNREVIEWED"}}],
"pageInfo":{"hasNextPage":false}
}}}}}));
}
return if provider == Provider::Github {
Ok(json!({"data":{"repository":{"p7":{"additions":1,"deletions":2,
"reviews":{"nodes":[{"state":"APPROVED","author":{"login":"viewer"}}]}}}}}))
} else {
Ok(json!({"data":{"project":{
"p7":{"diffStatsSummary":{"additions":1,"deletions":2}},
"approved":{"nodes":[{"iid":"7"}],"pageInfo":{"hasNextPage":false}}
}}}))
};
}
if url.contains("/7/reviews?") {
return Ok(if url.ends_with("page=1") {
json!([
{"id":10,"user":{"id":42},"state":"APPROVED","dismissed":false},
{"id":20,"user":{"id":42},"state":"REQUEST_REVIEW","dismissed":false}
])
} else {
json!([])
});
}
Ok(json!([{"number":7,"iid":7,"title":"Request again","created_at":"2024-01-01T00:00:00Z",
"updated_at":"2024-01-02T01:00:00+01:00",
"state":if provider == Provider::Gitlab { "opened" } else { "open" },
"merged":false,"merged_at":null,"user":{"login":"author"},"author":{"username":"author"},
"requested_reviewers":[{"login":"viewer","id":42}],"reviewers":[{"username":"viewer"}],
"additions":1,"deletions":2}]))
}).unwrap();
assert_eq!(page.entries.len(), 1);
assert!(page.entries[0].approved && page.entries[0].review_requested);
assert!(page.entries[0].description.contains("+1 -2"));
assert_eq!(page.entries[0].updated_at, "2024-01-02T00:00:00.000000000Z");
}
}
#[test]
fn draft_summaries_keep_open_closed_and_merged_states_distinct() {
for provider in [Provider::Github, Provider::Gitlab, Provider::Gitea] {
for (closed, merged, expected) in [
(false, false, "draft"),
(true, false, "closed"),
(true, true, "merged"),
] {
let mut value = json!({
"number": 7, "iid": 7, "title": "Unfinished work", "draft": true,
"state": if merged && provider == Provider::Gitlab { "merged" }
else if closed { "closed" }
else if provider == Provider::Gitlab { "opened" } else { "open" },
"merged": merged,
"merged_at": if merged { Some("2024-01-02T00:00:00Z") } else { None },
"user": null, "author": null, "created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-02T00:00:00Z"
});
let entry = summary(provider, &value, Some((1, 2))).unwrap();
assert!(entry.draft);
assert_eq!(entry.closed, closed);
assert_eq!(
entry.description,
format!("deleted user · {expected} · +1 -2")
);
if !closed {
value["draft"] = json!(false);
assert!(!summary(provider, &value, None).unwrap().draft);
value.as_object_mut().unwrap().remove("draft");
assert!(!summary(provider, &value, None).unwrap().draft);
value["draft"] = json!("true");
assert!(summary(provider, &value, None).is_err());
}
}
}
}
#[test]
fn pull_request_sizes_include_zero_and_preserve_unknown_statistics() {
for (url, provider) in [
("https://github.com/owner/repo", Provider::Github),
("https://github.enterprise/owner/repo", Provider::Github),
("https://gitlab.com/group/repo", Provider::Gitlab),
("https://codeberg.org/owner/repo", Provider::Gitea),
] {
let forge = parse(
url,
Some(match provider {
Provider::Github => "github",
Provider::Gitlab => "gitlab",
Provider::Gitea => "gitea",
}),
)
.unwrap();
let page = forge
.page_with(false, 1, |url, body| {
if url.ends_with("/user") {
return Err("unauthenticated fixture".into());
}
if let Some(body) = body {
let query: Value = serde_json::from_str(body).unwrap();
let query = query["query"].as_str().unwrap();
assert!(query.starts_with("query{"));
assert!(!query.contains("mutation"));
if provider == Provider::Github {
assert_eq!(
url,
if forge.host == "github.com" {
"https://api.github.com/graphql"
} else {
"https://github.enterprise/api/graphql"
}
);
assert!(query.contains("p7:pullRequest(number:7)"));
return Ok(json!({"data":{"repository":{
"p7":{"additions":123,"deletions":45},
"p8":{"additions":0,"deletions":0},
"p9":null
}}}));
}
assert_eq!(url, "https://gitlab.com/api/graphql");
assert!(query.contains("p7:mergeRequest(iid:\"7\")"));
return Ok(json!({"data":{"project":{
"p7":{"diffStatsSummary":{"additions":123,"deletions":45}},
"p8":{"diffStatsSummary":{"additions":0,"deletions":0}},
"p9":{"diffStatsSummary":null}
}}}));
}
Ok(Value::Array((7..=9).map(|id| json!({
"merged_at":null, "merged":false,
"number":id, "iid":id, "title":"Sized PR",
"state":if provider == Provider::Gitlab { "opened" } else { "open" },
"user":{"login":"author"}, "author":{"username":"author"},
"created_at":"2024-01-01T00:00:00Z",
"updated_at":"2024-01-02T00:00:00Z",
"base":{"ref":"main"}, "head":{"ref":"topic"},
"target_branch":"main", "source_branch":"topic",
"additions":match id { 7 => Some(123), 8 => Some(0), _ => None },
"deletions":match id { 7 => Some(45), 8 => Some(0), _ => None }
})).collect()))
})
.unwrap();
let description = |id| {
&page
.entries
.iter()
.find(|entry| entry.number == id)
.unwrap()
.description
};
assert!(description(7).contains("open · +123 -45 · topic → main"));
assert!(description(8).contains("open · +0 -0 · topic → main"));
assert!(description(9).contains("size unavailable"));
assert!(!description(9).contains("+0 -0"));
}
}
#[test] #[test]
fn pages_are_bounded_newest_first_summaries_without_historical_metadata() { fn pages_are_bounded_newest_first_summaries_without_historical_metadata() {
for (provider, url, query) in [ for (provider, url, query) in [
@@ -27,34 +648,38 @@ fn pages_are_bounded_newest_first_summaries_without_historical_metadata() {
), ),
] { ] {
let forge = parse(url, None).unwrap(); let forge = parse(url, None).unwrap();
let mut calls = 0; let page = forge.page_with(false, 3, |url, body| {
let page = forge.page_with(false, 3, |url| { if url.ends_with("/user") {
calls += 1; return Err("unauthenticated fixture".into());
}
if body.is_some() {
return Err("statistics unavailable".into());
}
assert_eq!(url, format!("{}?{query}", forge.endpoint)); assert_eq!(url, format!("{}?{query}", forge.endpoint));
Ok(json!([ Ok(json!([
{"number": 1, "iid": 1, "title": "Older", "state": if provider == Provider::Gitlab { "opened" } else { "open" }, {"number": 1, "iid": 1, "title": "Older", "state": if provider == Provider::Gitlab { "opened" } else { "open" },
"user": null, "author": null, "merged_at": null, "merged": false, "created_at": "2024-01-01T01:00:00+01:00"}, "user": null, "author": null, "merged_at": null, "merged": false, "created_at": "2024-01-01T01:00:00+01:00", "updated_at":"2024-02-01T01:00:00+01:00"},
{"number": 2, "iid": 2, "title": "Newer", "state": if provider == Provider::Gitlab { "opened" } else { "open" }, {"number": 2, "iid": 2, "title": "Newer", "state": if provider == Provider::Gitlab { "opened" } else { "open" },
"user": {"login": "author"}, "author": {"username": "author"}, "merged_at": null, "merged": false, "user": {"login": "author"}, "author": {"username": "author"}, "merged_at": null, "merged": false,
"created_at": "2024-01-01T00:00:00.001Z", "diff_refs": null, "head": {"sha": "not-a-commit"}} "created_at": "2024-01-01T00:00:00.001Z", "updated_at":"2024-02-01T00:00:00.001Z", "diff_refs": null, "head": {"sha": "not-a-commit"}}
])) ]))
}).unwrap(); }).unwrap();
assert_eq!(calls, 1);
assert_eq!( assert_eq!(
page.entries.iter().map(|p| p.number).collect::<Vec<_>>(), page.entries.iter().map(|p| p.number).collect::<Vec<_>>(),
[2, 1] [2, 1]
); );
assert_eq!(page.entries[0].description, "author · open");
assert!(page.entries[1].description.contains("deleted user")); assert!(page.entries[1].description.contains("deleted user"));
assert_eq!(page.entries[1].updated_at, "2024-02-01T00:00:00.000000000Z");
assert_eq!(page.entries[0].updated_at, "2024-02-01T00:00:00.001000000Z");
assert!( assert!(
page.has_more, page.has_more,
"A short, server-capped page is not exhaustion" "A short, server-capped page is not exhaustion"
); );
let empty = forge.page_with(false, 4, |_| Ok(json!([]))).unwrap(); let empty = forge.page_with(false, 4, |_, _| Ok(json!([]))).unwrap();
assert!(empty.entries.is_empty() && !empty.has_more); assert!(empty.entries.is_empty() && !empty.has_more);
assert!( assert!(
forge forge
.page_with(false, 0, |_| panic!("Page zero must not request")) .page_with(false, 0, |_, _| panic!("Page zero must not request"))
.is_err() .is_err()
); );
} }
@@ -67,7 +692,13 @@ fn gitlab_closed_combines_closed_and_merged_pages_without_open_requests() {
for page in 1..=3 { for page in 1..=3 {
let mut states = Vec::new(); let mut states = Vec::new();
let result = forge let result = forge
.page_with(true, page, |url| { .page_with(true, page, |url, body| {
if url.ends_with("/user") {
return Err("unauthenticated fixture".into());
}
if body.is_some() {
return Err("statistics unavailable".into());
}
assert!(url.ends_with(&format!("&page={page}"))); assert!(url.ends_with(&format!("&page={page}")));
let state = if url.contains("?state=closed&") { let state = if url.contains("?state=closed&") {
"closed" "closed"
@@ -83,7 +714,7 @@ fn gitlab_closed_combines_closed_and_merged_pages_without_open_requests() {
_ => return Ok(json!([])), _ => return Ok(json!([])),
}; };
Ok(json!([{"iid": id, "title": state, "state": state, Ok(json!([{"iid": id, "title": state, "state": state,
"author": null, "created_at": date, "diff_refs": null}])) "author": null, "created_at": date, "updated_at": date, "diff_refs": null}]))
}) })
.unwrap(); .unwrap();
assert_eq!(states, ["closed", "merged"]); assert_eq!(states, ["closed", "merged"]);
@@ -100,9 +731,15 @@ fn gitlab_closed_combines_closed_and_merged_pages_without_open_requests() {
} }
all.sort_unstable_by(|a, b| b.created_at.cmp(&a.created_at)); all.sort_unstable_by(|a, b| b.created_at.cmp(&a.created_at));
assert_eq!(all.iter().map(|p| p.number).collect::<Vec<_>>(), [3, 2, 1]); assert_eq!(all.iter().map(|p| p.number).collect::<Vec<_>>(), [3, 2, 1]);
let raced = forge.page_with(true, 1, |url| { let raced = forge.page_with(true, 1, |url, body| {
if url.ends_with("/user") {
return Err("unauthenticated fixture".into());
}
if body.is_some() {
return Err("statistics unavailable".into());
}
Ok(json!([{"iid": 7, "title": "Raced merge", "state": if url.contains("?state=merged&") { "merged" } else { "closed" }, Ok(json!([{"iid": 7, "title": "Raced merge", "state": if url.contains("?state=merged&") { "merged" } else { "closed" },
"author": null, "created_at": "2024-01-01T00:00:00Z"}])) "author": null, "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-02T00:00:00Z"}]))
}).unwrap(); }).unwrap();
assert_eq!(raced.entries.len(), 1); assert_eq!(raced.entries.len(), 1);
assert!(raced.entries[0].description.contains("merged")); assert!(raced.entries[0].description.contains("merged"));
@@ -124,7 +761,9 @@ fn timestamps_normalize_offsets_precision_and_calendar_boundaries() {
"2024-01-01T00:00:00.000000001Z", "2024-01-01T00:00:00.000000001Z",
), ),
] { ] {
assert_eq!(created_at(&json!({"created_at": input})).unwrap(), expected); for key in ["created_at", "updated_at"] {
assert_eq!(timestamp(&json!({(key): input}), key).unwrap(), expected);
}
} }
for input in [ for input in [
"", "",
@@ -139,12 +778,14 @@ fn timestamps_normalize_offsets_precision_and_calendar_boundaries() {
"0001-01-01T00:00:00+01:00", "0001-01-01T00:00:00+01:00",
"9999-12-31T23:00:00-02:00", "9999-12-31T23:00:00-02:00",
] { ] {
for key in ["created_at", "updated_at"] {
assert!( assert!(
created_at(&json!({"created_at": input})).is_err(), timestamp(&json!({(key): input}), key).is_err(),
"{input}" "{key}: {input}"
); );
} }
} }
}
#[test] #[test]
fn resolution_reads_only_selected_detail_and_exact_preserved_head() { fn resolution_reads_only_selected_detail_and_exact_preserved_head() {
+148
View File
@@ -0,0 +1,148 @@
use super::*;
use crate::pull_requests::{Direction, ReviewFilter, SortField, SortRule};
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"vamp-pr-preferences-{}-{}",
std::process::id(),
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&path).unwrap();
Self(path)
}
fn path(&self) -> PathBuf {
self.0.join("state/vamp/pull_requests.json")
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn paths_use_absolute_xdg_state_or_home_without_reading_environment() {
assert_eq!(
path_from(Some(OsStr::new("/state")), Some(OsStr::new("/home"))).unwrap(),
PathBuf::from("/state/vamp/pull_requests.json")
);
for xdg in [None, Some(OsStr::new("")), Some(OsStr::new("relative"))] {
assert_eq!(
path_from(xdg, Some(OsStr::new("/home"))).unwrap(),
PathBuf::from("/home/.local/state/vamp/pull_requests.json")
);
}
assert!(path_from(None, None).is_err());
assert!(path_from(None, Some(OsStr::new("relative"))).is_err());
}
#[test]
fn loading_is_lazy_and_applied_preferences_round_trip_atomically() {
let fixture = Fixture::new();
let path = fixture.path();
let (store, mut preferences, notice) = Store::load(Ok(path.clone()));
assert_eq!(preferences, Preferences::default());
assert!(notice.is_none());
assert!(!path.parent().unwrap().exists());
preferences.filters = vec![ReviewFilter::Approved];
preferences.sort.insert(
SortField::UpdatedAt,
SortRule {
precedence: Some(1),
direction: Direction::Asc,
},
);
store.save(&preferences).unwrap();
let (_, loaded, notice) = Store::load(Ok(path.clone()));
assert_eq!(loaded.sort, preferences.sort);
assert_eq!(loaded.filters, Preferences::default().filters);
assert!(!fs::read_to_string(&path).unwrap().contains("filters"));
assert!(notice.is_none());
preferences.filters.clear();
preferences.sort.clear();
store.save(&preferences).unwrap();
assert_eq!(Store::load(Ok(path)).1, Preferences::default());
assert_eq!(
fs::read_dir(fixture.0.join("state/vamp")).unwrap().count(),
1
);
}
#[test]
fn previously_saved_filters_are_ignored_without_changing_sort_or_file() {
let fixture = Fixture::new();
let path = fixture.path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
for filters in [
r#"[]"#,
r#"["approved"]"#,
r#"["unknown"]"#,
r#"{"invalid":true}"#,
] {
let bytes = format!(
r#"{{"version":1,"preferences":{{"sort":{{"created_at":{{"precedence":1,"direction":"asc"}}}},"filters":{filters}}}}}"#
);
fs::write(&path, &bytes).unwrap();
let (_, loaded, notice) = Store::load(Ok(path.clone()));
assert!(notice.is_none());
assert_eq!(loaded.filters, Preferences::default().filters);
assert_eq!(loaded.sort[&SortField::CreatedAt].direction, Direction::Asc);
assert_eq!(fs::read_to_string(&path).unwrap(), bytes);
}
}
#[test]
fn corrupt_incompatible_and_invalid_data_is_preserved_even_after_apply() {
let fixture = Fixture::new();
let path = fixture.path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
for bytes in [
"not json",
r#"{"version":2,"preferences":{}}"#,
r#"{"version":1,"preferences":{"sort":{"unknown":{}}}}"#,
r#"{"version":1,"preferences":{"sort":{"updated_at":{"direction":"up"}}}}"#,
r#"{"version":1,"preferences":{"sort":{"updated_at":{"precedence":0}}}}"#,
r#"{"version":1,"preferences":{"sort":{"updated_at":{"precedence":1},"created_at":{"precedence":1}}}}"#,
] {
fs::write(&path, bytes).unwrap();
let (store, loaded, notice) = Store::load(Ok(path.clone()));
assert_eq!(loaded, Preferences::default());
assert!(notice.unwrap().contains("session-only"));
assert!(store.save(&loaded).unwrap_err().contains("preserved"));
assert_eq!(fs::read_to_string(&path).unwrap(), bytes);
}
}
#[test]
fn failed_writes_and_newly_incompatible_data_are_reported_without_replacement() {
let fixture = Fixture::new();
let path = fixture.path();
let (store, preferences, _) = Store::load(Ok(path.clone()));
fs::write(fixture.0.join("state"), "keep this file").unwrap();
assert!(
store
.save(&preferences)
.unwrap_err()
.contains("session-only")
);
assert_eq!(
fs::read_to_string(fixture.0.join("state")).unwrap(),
"keep this file"
);
fs::remove_file(fixture.0.join("state")).unwrap();
fs::create_dir_all(path.parent().unwrap()).unwrap();
let incompatible = r#"{"version":9,"preferences":{}}"#;
fs::write(&path, incompatible).unwrap();
assert!(
store
.save(&preferences)
.unwrap_err()
.contains("unsupported")
);
assert_eq!(fs::read_to_string(path).unwrap(), incompatible);
}
+74
View File
@@ -463,6 +463,80 @@ fn working_jj_conflicted_parent_uses_cli_projection_without_snapshotting() {
} }
} }
#[test]
fn jj_catalog_exposes_fixed_metadata_and_merge_parents_without_snapshotting() {
let temp = Temporary::new().unwrap();
let root = &temp.0;
fixture_command(root, "jj", &["git", "init", "--no-colocate", "."]);
fixture_command(root, "jj", &["describe", "-m", "Base"]);
let id = |root: &Path| {
fixture_command(
root,
"jj",
&[
"--ignore-working-copy",
"log",
"--no-graph",
"-r",
"@",
"-T",
"commit_id",
],
)
};
let base = id(root);
fixture_command(root, "jj", &["bookmark", "create", "base"]);
fixture_command(root, "jj", &["new", &base, "-m", "Left"]);
let left = id(root);
fixture_command(root, "jj", &["new", &base, "-m", "Right"]);
let right = id(root);
fixture_command(root, "jj", &["new", &left, &right, "-m", "Merge"]);
let current = id(root);
let operation = fixture_command(
root,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id",
],
);
let mut repository = Repository::open(root, Arc::new(AtomicBool::new(false))).unwrap();
let catalog = repository.catalog().unwrap();
let merge = &catalog.graph[&current];
assert_eq!(merge.parents, [left, right]);
assert_eq!(merge.author, "Picker Author");
assert_eq!(merge.timestamp, "2001-01-01 00:00");
assert_eq!(merge.description, "Merge");
assert!(!merge.change_id.is_empty());
assert!(merge.current);
assert_eq!(catalog.graph[&base].bookmarks, "base");
assert!(!catalog.graph[&base].current);
assert!(catalog.graph[&"0".repeat(40)].immutable);
assert_eq!(
fixture_command(
root,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id"
]
),
operation
);
}
#[test] #[test]
fn cancellation_kills_pipe_holding_children_and_drains_both_streams() { fn cancellation_kills_pipe_holding_children_and_drains_both_streams() {
let temp = Temporary::new().unwrap(); let temp = Temporary::new().unwrap();
+675 -55
View File
@@ -1,5 +1,72 @@
use super::*; use super::*;
#[test]
fn text_excerpts_use_detected_languages_and_cannot_close_their_own_fences() {
use crate::plugin::TextPosition;
for (path, content, start_row, expected) in [
(
"source.hon",
"[8]Builtin",
0,
"```hon\n[8]Builtin\n```\nFeedback **outside**\n",
),
(
"source.rs",
"let value = \"界\";\n",
0,
"```rust\nlet value = \"界\";\n```\nFeedback **outside**\n",
),
(
"Makefile",
"all:\n\techo ok",
0,
"```makefile\nall:\n\techo ok\n```\nFeedback **outside**\n",
),
(
"script",
"#!/usr/bin/env python3\nprint('界')\n",
1,
"```python\nprint('界')\n```\nFeedback **outside**\n",
),
(
"unknown.data",
"e\u{301}👩‍💻\n界",
0,
"```\ne\u{301}👩‍💻\n界\n```\nFeedback **outside**\n",
),
(
"example.md",
"```rust\n````\n```",
0,
"`````markdown\n```rust\n````\n```\n`````\nFeedback **outside**\n",
),
] {
let mut review = Review::default();
review.comments.push(Comment {
id: 1,
resolved: false,
body: "Feedback **outside**".into(),
anchor: Anchor {
revision: "snapshot".into(),
path: path.into(),
side: Side::After,
start: TextPosition {
row: start_row,
column: 0,
},
end: TextPosition {
row: content.split('\n').count() - 1,
column: content.rsplit('\n').next().unwrap().graphemes(true).count(),
},
content: content.into(),
},
});
let text = review.format_text(false);
assert!(text.ends_with(expected), "{path}: {text}");
}
}
struct Directory(PathBuf); struct Directory(PathBuf);
impl Drop for Directory { impl Drop for Directory {
fn drop(&mut self) { fn drop(&mut self) {
@@ -38,6 +105,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,24 +127,34 @@ 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
.mapped_range(
"working:new-snapshot", "working:new-snapshot",
Path::new("日本語.rs"), Path::new("日本語.rs"),
Side::Before, Side::Before,
&["changed".into()], &["changed".into()],
true, true,
)); )
.is_none()
);
let path = root.join("export.json"); let path = root.join("export.json");
loaded.save(&path).unwrap(); loaded.save(&path).unwrap();
let exported = fs::read(&path).unwrap(); let exported = fs::read(&path).unwrap();
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"], 5);
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 +165,161 @@ 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]
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]
@@ -122,12 +361,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 +384,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,9 +400,26 @@ 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!(
"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');
PRAGMA user_version = 3"
);
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,
revision INTEGER NOT NULL, document TEXT NOT NULL) STRICT;
INSERT INTO reviews VALUES (1, '/old/repo', 1, '{\"version\":3}');
PRAGMA user_version = 1",
"", "",
] { ] {
let directory = directory(); let directory = directory();
@@ -159,50 +428,81 @@ 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);
} }
} }
#[test] #[test]
fn malformed_or_mismatched_database_documents_are_rejected() { fn malformed_database_rows_are_rejected_before_loading_snapshots() {
let other = directory();
let directory = directory(); let directory = directory();
let root = directory.0.canonicalize().unwrap(); let root = directory.0.canonicalize().unwrap();
let mut store = Store::open(&root.join("storage")).unwrap(); let mut store = Store::open(&root.join("storage")).unwrap();
let mut review = Review::new(root.clone()); let mut review = Review::new(root.clone());
review.comments.push(Comment {
id: 1,
body: "Feedback".into(),
resolved: false,
anchor: Anchor {
revision: "snapshot".into(),
path: "file.rs".into(),
side: Side::After,
start: crate::plugin::TextPosition { row: 0, column: 0 },
end: crate::plugin::TextPosition { row: 0, column: 1 },
content: "界\n".into(),
},
});
store.save(&mut review).unwrap(); store.save(&mut review).unwrap();
let id = review.storage_id.unwrap(); let id = review.storage_id.unwrap();
let mut wrong_root = serde_json::to_value(&review).unwrap(); for update in [
wrong_root["root"] = serde_json::json!(other.0.canonicalize().unwrap()); "resolved = 2",
let mut invalid_version = serde_json::to_value(&review).unwrap(); "side = 'invalid'",
invalid_version["version"] = 1.into(); "id = '0'",
let mut inherited_identity = serde_json::to_value(&review).unwrap(); "id = '18446744073709551616'",
inherited_identity["storage_id"] = id.into(); "start_row = -1",
for document in [ "end_column = -1",
"{".to_owned(),
serde_json::to_string(&wrong_root).unwrap(),
serde_json::to_string(&invalid_version).unwrap(),
serde_json::to_string(&inherited_identity).unwrap(),
] { ] {
assert!(
store store
.connection .connection
.execute( .execute(
"UPDATE reviews SET document = ?1 WHERE id = ?2", &format!("UPDATE comments SET {update} WHERE review_id = ?1"),
params![document, id], [id],
) )
.unwrap(); .is_err()
assert!(store.load(&root, id).is_err()); );
assert!(store.list(&root).is_err()); assert_eq!(store.load(&root, id).unwrap().comments, review.comments);
} }
store store
.connection .connection
.execute_batch("PRAGMA ignore_check_constraints = ON") .execute_batch("PRAGMA ignore_check_constraints = ON")
.unwrap(); .unwrap();
for update in [
"path = '../outside'",
"end_column = 2",
"resolved = 2",
"side = 'invalid'",
"anchor_revision = ''",
"id = '0'",
"position = 1",
] {
store store
.connection .connection
.execute( .execute(
"UPDATE reviews SET document = CAST(zeroblob(?1) AS TEXT) WHERE id = ?2", &format!("UPDATE comments 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 comments SET content = CAST(zeroblob(?1) AS TEXT) WHERE review_id = ?2",
params![LIMIT as i64 + 1, id], params![LIMIT as i64 + 1, id],
) )
.unwrap(); .unwrap();
@@ -299,6 +599,92 @@ fn default_directory_uses_absolute_xdg_then_absolute_home() {
assert!(resolve(None, Some("")).is_err()); assert!(resolve(None, Some("")).is_err());
} }
#[test]
fn anchor_survives_unrelated_edit() {
let anchor = Anchor {
revision: "working:old".into(),
path: "source.rs".into(),
side: Side::After,
start: crate::plugin::TextPosition { row: 1, column: 0 },
end: crate::plugin::TextPosition { row: 1, column: 6 },
content: "before\ntarget\nafter\n".into(),
};
let map = |text: &str| {
anchor.mapped_range(
"working:new",
Path::new("source.rs"),
Side::After,
&text
.trim_end_matches('\n')
.split('\n')
.map(str::to_owned)
.collect::<Vec<_>>(),
text.ends_with('\n'),
)
};
assert_eq!(
map("changed\ntarget\nafter\n"),
Some(anchor.start..anchor.end)
);
assert_eq!(
map("inserted\nbefore\ntarget\nafter\n"),
Some(
crate::plugin::TextPosition { row: 2, column: 0 }..crate::plugin::TextPosition {
row: 2,
column: 6
}
)
);
assert!(map("before\ntargEt\nafter\n").is_none());
assert!(map("before\nafter\n").is_none());
assert!(map("before\ntarget\nafter\nbefore\ntarget\nafter\n").is_none());
assert_eq!(map("target\nbefore\ntarget\nafter\n").unwrap().start.row, 2);
assert_eq!(anchor.content, "before\ntarget\nafter\n");
let partial = Anchor {
content: "header\r\nprefix 👩‍💻e\u{301} suffix\r\nfooter\r\n".into(),
start: crate::plugin::TextPosition { row: 1, column: 7 },
end: crate::plugin::TextPosition { row: 1, column: 9 },
..anchor.clone()
};
let moved = partial
.mapped_range(
"working:new",
Path::new("source.rs"),
Side::After,
&[
"inserted\r".into(),
"header\r".into(),
"longer prefix 👩‍💻e\u{301} suffix\r".into(),
"footer\r".into(),
],
true,
)
.unwrap();
assert_eq!(
moved.start,
crate::plugin::TextPosition { row: 2, column: 14 }
);
assert_eq!(
moved.end,
crate::plugin::TextPosition { row: 2, column: 16 }
);
assert!(
partial
.mapped_range(
"other-commit",
Path::new("source.rs"),
Side::After,
&[
"header\r".into(),
"prefix 👩‍💻e\u{301} suffix\r".into(),
"footer\r".into()
],
true
)
.is_none()
);
}
#[test] #[test]
fn persistence_validates_anchors_and_preserves_files_on_failure() { fn persistence_validates_anchors_and_preserves_files_on_failure() {
let directory = directory(); let directory = directory();
@@ -325,27 +711,39 @@ fn persistence_validates_anchors_and_preserves_files_on_failure() {
assert_eq!(loaded.comments, review.comments); assert_eq!(loaded.comments, review.comments);
assert_eq!(loaded.verdict, review.verdict); assert_eq!(loaded.verdict, review.verdict);
let anchor = &loaded.comments[0].anchor; let anchor = &loaded.comments[0].anchor;
assert!(!anchor.matches_lines( assert!(
anchor
.mapped_range(
"working:other", "working:other",
Path::new("source.rs"), Path::new("source.rs"),
Side::After, Side::After,
&["first".into(), "last".into()], &["first".into(), "last".into()],
true true
)); )
assert!(anchor.matches_lines( .is_some()
);
assert!(
anchor
.mapped_range(
"working:immutable", "working:immutable",
Path::new("source.rs"), Path::new("source.rs"),
Side::After, Side::After,
&["first".into(), "last".into()], &["first".into(), "last".into()],
true true
)); )
assert!(!anchor.matches_lines( .is_some()
);
assert!(
anchor
.mapped_range(
"working:immutable", "working:immutable",
Path::new("source.rs"), Path::new("source.rs"),
Side::After, Side::After,
&["first".into(), "last".into()], &["first".into(), "last".into()],
false false
)); )
.is_none()
);
loaded.summary = "Updated".into(); loaded.summary = "Updated".into();
loaded.save(&path).unwrap(); loaded.save(&path).unwrap();
assert_eq!(Review::load(&path, &root).unwrap().summary, "Updated"); assert_eq!(Review::load(&path, &root).unwrap().summary, "Updated");
@@ -406,43 +804,109 @@ fn persistence_validates_anchors_and_preserves_files_on_failure() {
} }
#[test] #[test]
fn stored_version_two_is_rejected_without_rewriting_it() { fn comment_rows_preserve_order_full_ids_and_atomic_replacement() {
let directory = directory(); let directory = directory();
let root = directory.0.canonicalize().unwrap(); let root = directory.0.canonicalize().unwrap();
let mut store = Store::open(&root.join("storage")).unwrap(); let mut store = Store::open(&root.join("storage")).unwrap();
let mut review = Review::new(root.clone()); let mut review = Review::new(root.clone());
review.comments = [u64::MAX, 7]
.into_iter()
.map(|id| Comment {
id,
body: format!("Feedback {id}"),
resolved: id == 7,
anchor: Anchor {
revision: "snapshot".into(),
path: "source.rs".into(),
side: Side::Before,
start: crate::plugin::TextPosition { row: 0, column: 0 },
end: crate::plugin::TextPosition { row: 0, column: 1 },
content: "界\n".into(),
},
})
.collect();
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 mut old = serde_json::to_value(&review).unwrap(); let id = review.storage_id.unwrap();
old["version"] = 2.into(); assert_eq!(store.load(&root, id).unwrap().comments, review.comments);
let document = serde_json::to_string(&old).unwrap(); assert!(
store store
.connection .connection
.execute( .execute(
"UPDATE reviews SET document = ?1 WHERE id = ?2", "UPDATE comments SET review_id = ?1 WHERE review_id = ?2",
params![document, review.storage_id.unwrap()], params![id + 1, id],
) )
.unwrap(); .is_err()
assert!(
store
.load(&root, review.storage_id.unwrap())
.unwrap_err()
.contains("Unsupported review version 2")
); );
assert!( assert!(
store store
.list(&root)
.unwrap_err()
.contains("Unsupported review version 2")
);
let preserved: String = store
.connection .connection
.query_row( .execute("UPDATE comments SET id = '7' WHERE review_id = ?1", [id],)
"SELECT document FROM reviews WHERE id = ?1", .is_err()
[review.storage_id.unwrap()], );
|row| row.get(0), store
.connection
.execute_batch(
"CREATE TEMP TRIGGER reject_comment BEFORE INSERT ON comments
WHEN NEW.id = '7' BEGIN SELECT RAISE(ABORT, 'blocked comment'); END",
) )
.unwrap(); .unwrap();
assert_eq!(preserved, document); let revision = review.storage_revision;
let original = review.comments.clone();
let original_files = review.reviewed_files.clone();
review.summary = "Unsaved".into();
review.comments[0].body = "Changed".into();
assert!(
store
.save(&mut review)
.unwrap_err()
.contains("blocked comment")
);
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.storage_revision, revision);
assert_eq!(loaded.reviewed_files, original_files);
store
.connection
.execute_batch("DROP TRIGGER reject_comment")
.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();
store.save(&mut review).unwrap();
assert_eq!(store.load(&root, id).unwrap().comments, review.comments);
review.comments.pop();
store.save(&mut review).unwrap();
assert_eq!(store.load(&root, id).unwrap().comments, review.comments);
review.comments.clear();
store.save(&mut review).unwrap();
assert!(store.load(&root, id).unwrap().comments.is_empty());
assert_eq!(store.list(&root).unwrap()[0].summary, "Unsaved");
} }
#[test] #[test]
@@ -475,16 +939,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, 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();
assert!(error.contains(if version == 2 { if version < 5 {
"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 [
@@ -512,3 +976,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"));
}
+39 -1
View File
@@ -1,7 +1,7 @@
use super::*; use super::*;
use vamp::{ use vamp::{
plugin::TextPosition, plugin::TextPosition,
review::{Comment, Side}, review::{Anchor, Comment, Side},
}; };
#[test] #[test]
@@ -105,3 +105,41 @@ fn existing_storage_is_read_only_and_unchanged() {
} }
std::fs::remove_dir_all(directory).unwrap(); std::fs::remove_dir_all(directory).unwrap();
} }
#[test]
fn comment_mutations_require_positive_explicit_ids_and_reject_all() {
for command in ["delete", "resolve"] {
for ids in [
vec![],
vec!["1"],
vec!["0", "1"],
vec!["-1", "1"],
vec!["9223372036854775808", "1"],
vec!["all", "1"],
vec!["1", "0"],
vec!["1", "-1"],
vec!["1", "18446744073709551616"],
vec!["1", "--json"],
] {
let mut args = std::iter::once(command).chain(ids).map(OsString::from);
assert!(Request::parse(&mut args).is_err());
}
let mut args = [command, "9223372036854775807", "18446744073709551615"]
.into_iter()
.map(OsString::from);
let mut request = Request::parse(&mut args).unwrap();
assert_eq!(request.id, Some(i64::MAX));
let (action, id) = request.comment.as_ref().unwrap();
assert_eq!(*id, u64::MAX);
assert!(matches!(
(command, action),
("delete", CommentAction::Delete) | ("resolve", CommentAction::Resolve)
));
assert!(request.option(OsStr::new("--all"), &mut args).is_err());
request.option(OsStr::new("--json"), &mut args).unwrap();
let mut repo = [OsString::from("checkout")].into_iter();
request.option(OsStr::new("--repo"), &mut repo).unwrap();
assert!(request.json);
assert_eq!(request.repo, Some(PathBuf::from("checkout")));
}
}
+227 -6
View File
@@ -106,7 +106,7 @@ fn review_directory_resolves_config_paths_and_keeps_session_startup_lazy() {
} }
#[test] #[test]
fn review_directory_wire_field_is_required_nullable_and_validated() { fn review_directory_and_review_symbol_wire_fields_are_required_and_validated() {
let mut frame = serde_json::json!({ let mut frame = serde_json::json!({
"version": 1, "type": "config", "plugins": [], "lsp": {}, "bindings": [], "version": 1, "type": "config", "plugins": [], "lsp": {}, "bindings": [],
"review_directory": null, "leader": "space", "review_directory": null, "leader": "space",
@@ -119,6 +119,7 @@ fn review_directory_wire_field_is_required_nullable_and_validated() {
"diff_context_lines": 10, "diff_expand_lines": 15, "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical", "diff_view_mode": "split", "diff_split_orientation": "vertical",
"resolve_history_layout": "columns", "resolve_history_layout": "columns",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true "diff_follow_working_copy": true
}); });
for value in [serde_json::Value::Null, serde_json::json!("reviews")] { for value in [serde_json::Value::Null, serde_json::json!("reviews")] {
@@ -136,10 +137,83 @@ fn review_directory_wire_field_is_required_nullable_and_validated() {
frame["review_directory"] = value; frame["review_directory"] = value;
assert!(serde_json::from_value::<Response>(frame.clone()).is_err()); assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
} }
frame["review_directory"] = serde_json::Value::Null;
for field in [
"pull_request_review_requested_symbol",
"pull_request_approved_symbol",
] {
for value in ["", "👩‍🚀 R", "ok"] {
frame[field] = serde_json::json!(value);
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
}
for value in [
serde_json::json!(null),
serde_json::json!(false),
serde_json::json!(7),
serde_json::json!([]),
serde_json::json!({}),
serde_json::json!("\0"),
serde_json::json!("\n"),
serde_json::json!("\t"),
serde_json::json!("\u{7f}"),
serde_json::json!("\u{85}"),
] {
frame[field] = value;
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
}
frame.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
frame[field] = serde_json::json!("");
}
frame.as_object_mut().unwrap().remove("review_directory"); frame.as_object_mut().unwrap().remove("review_directory");
assert!(serde_json::from_value::<Response>(frame).is_err()); assert!(serde_json::from_value::<Response>(frame).is_err());
} }
#[test]
fn pull_request_symbols_load_defaults_custom_and_empty_and_reject_invalid_ruby_values() {
let fixture = Fixture::new();
let path = fixture.file("config.rb", "Vamp.configure { |_| }");
let config = load_config(&path).unwrap();
assert_eq!(config.pull_request_review_requested_symbol, "🙏");
assert_eq!(config.pull_request_approved_symbol, "👍");
for symbol in ["", "👩‍🚀 R", "ok"] {
let value = serde_json::to_string(symbol).unwrap();
let path = fixture.file("config.rb", &format!(
"Vamp.configure {{ |c| c.pull_request_review_requested_symbol = {value}; c.pull_request_approved_symbol = {value} }}"
));
let config = load_config(&path).unwrap();
assert_eq!(config.pull_request_review_requested_symbol, symbol);
assert_eq!(config.pull_request_approved_symbol, symbol);
}
for field in [
"pull_request_review_requested_symbol",
"pull_request_approved_symbol",
] {
for value in [
"nil",
"false",
"7",
"[]",
"{}",
":ok",
r#""\n""#,
r#""\t""#,
r#""\0""#,
r#""\u007f""#,
r#""\u0085""#,
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |c| c.{field} = {value} }}"),
);
assert!(
load_config(&path).unwrap_err().contains(field),
"{field}: {value}"
);
}
}
}
#[test] #[test]
fn tab_spaces_follow_active_language_and_preserve_native_edit_semantics() { fn tab_spaces_follow_active_language_and_preserve_native_edit_semantics() {
let fixture = Fixture::new(); let fixture = Fixture::new();
@@ -544,9 +618,9 @@ fn config_leader_defaults_custom_keys_validation_and_required_wire_field() {
assert!(load_config(&path).unwrap_err().contains(error), "{value}"); assert!(load_config(&path).unwrap_err().contains(error), "{value}");
} }
for frame in [ for frame in [
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#, r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"pull_request_review_requested_symbol":"🙏","pull_request_approved_symbol":"👍","resolve_history_layout":"columns"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#, r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"pull_request_review_requested_symbol":"🙏","pull_request_approved_symbol":"👍","resolve_history_layout":"columns"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#, r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"pull_request_review_requested_symbol":"🙏","pull_request_approved_symbol":"👍","resolve_history_layout":"columns"}"#,
] { ] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}"); assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
} }
@@ -579,6 +653,69 @@ fn git_gutter_config_modes_default_and_rejection() {
} }
} }
#[test]
fn cursor_config_partial_shapes_and_invalid_options() {
use crate::cursor::CursorShape::{Bar, Block, Underline};
let fixture = Fixture::new();
for (options, mode_colors, shapes) in [
(
r#"{ insert: :bar, "visual" => "underline" }"#,
true,
[Block, Bar, Block, Block, Underline],
),
(
r#"{ mode_colors: false, normal: "bar", insert: :underline, command: :bar, search: "underline", visual: :block }"#,
false,
[Bar, Underline, Bar, Underline, Block],
),
(
r#"{ "mode_colors" => true, normal: :underline, insert: "block", command: "underline", search: :bar, visual: "bar" }"#,
true,
[Underline, Block, Underline, Bar, Bar],
),
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.cursor = {options} }}"),
);
let cursor = load_config(&path).unwrap().cursor;
assert_eq!(cursor.mode_colors, mode_colors);
assert_eq!(
[
cursor.normal,
cursor.insert,
cursor.command,
cursor.search,
cursor.visual
],
shapes
);
}
for options in [
"nil",
"false",
"[]",
"{ mode_colors: nil }",
"{ mode_colors: 1 }",
r#"{ mode_colors: "true" }"#,
"{ insert: nil }",
"{ insert: false }",
"{ insert: 1 }",
"{ insert: :beam }",
r#"{ normal: "" }"#,
"{ unknown: :block }",
"{ 1 => :block }",
r#"{ insert: :bar, "insert" => :block }"#,
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.cursor = {options} }}"),
);
assert!(load_config(&path).is_err(), "{options}");
}
}
#[test] #[test]
fn git_blame_boolean_config_typed_toggle_palette_and_wire_validation() { fn git_blame_boolean_config_typed_toggle_palette_and_wire_validation() {
let fixture = Fixture::new(); let fixture = Fixture::new();
@@ -632,6 +769,7 @@ end
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15, "labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical", "diff_view_mode": "split", "diff_split_orientation": "vertical",
"resolve_history_layout": "columns", "resolve_history_layout": "columns",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true "diff_follow_working_copy": true
}); });
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok()); assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
@@ -773,6 +911,7 @@ end"#,
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15, "labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical", "diff_view_mode": "split", "diff_split_orientation": "vertical",
"resolve_history_layout": "columns", "resolve_history_layout": "columns",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true "diff_follow_working_copy": true
}); });
for value in ["all", "leading"] { for value in ["all", "leading"] {
@@ -931,7 +1070,6 @@ end
let loaded = load_config(&path).unwrap(); let loaded = load_config(&path).unwrap();
assert_eq!(loaded.top_bar.left, ["mode", "language"]); assert_eq!(loaded.top_bar.left, ["mode", "language"]);
assert_eq!(loaded.top_bar.segment("mode").padding, 0); assert_eq!(loaded.top_bar.segment("mode").padding, 0);
assert_eq!(loaded.top_bar.segment("language").padding, 1);
assert_eq!(loaded.bottom_bar.left, ["tabs"]); assert_eq!(loaded.bottom_bar.left, ["tabs"]);
assert_eq!(loaded.bottom_bar.segment("tabs").title, "{name}{modified}"); assert_eq!(loaded.bottom_bar.segment("tabs").title, "{name}{modified}");
@@ -1037,6 +1175,7 @@ fn config_diff_excerpt_counts_accept_custom_values_and_reject_invalid_values() {
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7, "clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7,
"diff_view_mode": "split", "diff_split_orientation": "vertical", "diff_view_mode": "split", "diff_split_orientation": "vertical",
"resolve_history_layout": "columns", "resolve_history_layout": "columns",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true "diff_follow_working_copy": true
}); });
for setting in ["diff_context_lines", "diff_expand_lines"] { for setting in ["diff_context_lines", "diff_expand_lines"] {
@@ -1149,6 +1288,7 @@ fn config_scroll_distance_accepts_nil_and_counts_and_rejects_invalid_values() {
"diff_context_lines": 10, "diff_expand_lines": 15, "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical", "diff_view_mode": "split", "diff_split_orientation": "vertical",
"resolve_history_layout": "columns", "resolve_history_layout": "columns",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true, "scroll_distance": 0 "diff_follow_working_copy": true, "scroll_distance": 0
}); });
for distance in [None, Some(0), Some(7), Some(usize::MAX)] { for distance in [None, Some(0), Some(7), Some(usize::MAX)] {
@@ -1203,6 +1343,7 @@ fn lsp_configuration_is_disabled_unless_explicitly_enabled() {
r#"Vamp.configure do |config| r#"Vamp.configure do |config|
config.lsp["rust"] = false config.lsp["rust"] = false
config.lsp["python"] = false config.lsp["python"] = false
config.lsp["typst"] = false
config.lsp["not-a-preset"] = false config.lsp["not-a-preset"] = false
end"#, end"#,
] { ] {
@@ -1222,13 +1363,15 @@ fn lsp_presets_custom_languages_and_overrides_reach_native_configuration() {
config.lsp["rust"] = true config.lsp["rust"] = true
config.lsp["python"] = true config.lsp["python"] = true
config.lsp["ruby"] = true config.lsp["ruby"] = true
config.lsp["typst"] = true
end"#, end"#,
); );
let config = load_config(&path).unwrap(); let config = load_config(&path).unwrap();
assert_eq!(config.lsp.len(), 4); assert_eq!(config.lsp.len(), 5);
for (language, command, extension) in [ for (language, command, extension) in [
("rust", &["rust-analyzer"][..], "rs"), ("rust", &["rust-analyzer"][..], "rs"),
("ruby", &["solargraph", "stdio"][..], "rb"), ("ruby", &["solargraph", "stdio"][..], "rb"),
("typst", &["tinymist", "lsp"][..], "typ"),
] { ] {
let server = config let server = config
.lsp .lsp
@@ -1583,6 +1726,7 @@ fn lsp_wire_configuration_is_resolved_after_decoding() {
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15, "clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical", "diff_view_mode": "split", "diff_split_orientation": "vertical",
"resolve_history_layout": "columns", "resolve_history_layout": "columns",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true "diff_follow_working_copy": true
}); });
let Response::Config(config) = serde_json::from_value::<Response>(frame.clone()).unwrap() let Response::Config(config) = serde_json::from_value::<Response>(frame.clone()).unwrap()
@@ -2278,3 +2422,80 @@ fn ruby_rejects_invalid_document_context_and_sync_async_dsl_usage() {
plugin.host.child.is_none() && plugin.host.reader.is_none() && plugin.host.writer.is_none() plugin.host.child.is_none() && plugin.host.reader.is_none() && plugin.host.writer.is_none()
); );
} }
#[test]
fn pull_request_preferences_distinguish_unset_nil_and_explicit_sections() {
use crate::pull_requests::{Direction, ReviewFilter, SortField};
let fixture = Fixture::new();
for assignment in [
"",
"config.pull_request_sort = nil; config.pull_request_filters = nil",
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| {assignment} }}"),
);
let config = load_config(&path).unwrap();
assert!(config.pull_request_sort.is_none());
assert!(config.pull_request_filters.is_none());
}
let path = fixture.file(
"config.rb",
r#"
Vamp.configure do |config|
config.pull_request_sort = {
updated_at: {precedence: 1, direction: :desc},
created_at: {direction: :asc}
}
config.pull_request_filters = [:requested, :approved]
end
"#,
);
let config = load_config(&path).unwrap();
let sort = config.pull_request_sort.unwrap();
assert_eq!(sort[&SortField::UpdatedAt].precedence, Some(1));
assert_eq!(sort[&SortField::UpdatedAt].direction, Direction::Desc);
assert_eq!(sort[&SortField::CreatedAt].precedence, None);
assert_eq!(sort[&SortField::CreatedAt].direction, Direction::Asc);
assert_eq!(
config.pull_request_filters.unwrap(),
[ReviewFilter::Requested, ReviewFilter::Approved]
);
let path = fixture.file("config.rb", "Vamp.configure { |config| config.pull_request_sort = {}; config.pull_request_filters = [] }");
let config = load_config(&path).unwrap();
assert!(config.pull_request_sort.unwrap().is_empty());
assert!(config.pull_request_filters.unwrap().is_empty());
}
#[test]
fn pull_request_preferences_reject_unknown_values_and_invalid_ranks() {
let fixture = Fixture::new();
for (field, value) in [
("sort", "[]"),
("sort", "{unknown: {}}"),
("sort", "{updated_at: []}"),
("sort", "{updated_at: {unknown: 1}}"),
("sort", "{updated_at: {direction: :up}}"),
("sort", "{updated_at: {direction: nil}}"),
("sort", "{updated_at: {precedence: 0}}"),
("sort", "{updated_at: {precedence: 5}}"),
("sort", "{updated_at: {precedence: 1.0}}"),
(
"sort",
"{updated_at: {precedence: 1}, created_at: {precedence: 1}}",
),
("filters", "{}"),
("filters", "[:unknown]"),
("filters", "[:requested, :requested]"),
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.pull_request_{field} = {value} }}"),
);
let error = load_config(&path).unwrap_err();
assert!(
error.contains(&format!("pull_request_{field}")),
"{value}: {error}"
);
}
}
+307
View File
@@ -4,6 +4,132 @@ use std::{
sync::atomic::{AtomicU64, Ordering}, sync::atomic::{AtomicU64, Ordering},
}; };
#[cfg(unix)]
#[test]
fn concurrent_shell_commands_share_status_and_queue_every_completed_output() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "source");
let mut session = fixture.open("");
session.editor.set_project_root(fixture.0.clone());
session.execute("!printf FIRST; while [ ! -f first ]; do sleep 0.01; done");
session.execute("!echo SECOND; while [ ! -f second ]; do sleep 0.01; done; exit 9");
assert_eq!(session.shell.len(), 2);
let fixed = Instant::now();
for running in &mut session.shell {
running.started = fixed;
}
session.poll_shell_at(fixed + Duration::from_millis(199));
assert_eq!(session.editor.shell_label(), "");
session.poll_shell_at(fixed + Duration::from_millis(200));
assert_eq!(session.editor.shell_label(), "pri... - ech... -");
fixture.file("second", "");
drain_until(&mut session, |session| session.shell.len() == 1);
assert_eq!(
session.editor.command_output().unwrap().content.lines[0],
"SECOND"
);
assert!(
session
.editor
.command_output()
.unwrap()
.title
.contains("exit 9")
);
assert!(session.editor.shell_label().starts_with("pri... "));
assert!(!session.editor.shell_label().contains("ech..."));
fixture.file("first", "");
drain_until(&mut session, |session| session.shell.is_empty());
assert_eq!(session.editor.shell_label(), "");
assert_eq!(
session.editor.command_output().unwrap().content.lines[0],
"SECOND"
);
session.handle(Input::Enter);
assert_eq!(
session.editor.command_output().unwrap().content.lines,
["FIRST"]
);
assert!(
session
.editor
.command_output()
.unwrap()
.title
.contains("exit 0")
);
session.handle(Input::Escape);
assert!(session.editor.command_output().is_none());
assert_eq!(session.editor.lines(), ["source"]);
}
#[cfg(unix)]
#[test]
fn shell_commands_leave_editing_live_and_present_both_streams_and_exit_status() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "source");
let mut session = fixture.open("");
session.editor.set_project_root(fixture.0.clone());
for ch in
":!while [ ! -f release ]; do sleep 0.01; done; pwd; printf 'out'; printf 'err' >&2; exit 7"
.chars()
{
session.handle(Input::Char(ch));
}
session.handle(Input::Enter);
let fixed = Instant::now();
session.shell[0].started = fixed;
assert!(!session.poll_shell_at(fixed + Duration::from_millis(199)));
assert_eq!(session.editor.shell_label(), "");
assert!(session.poll_shell_at(fixed + Duration::from_millis(200)));
assert_eq!(session.editor.shell_label(), "whi... -");
session.handle(Input::Char('i'));
session.handle(Input::Char('X'));
assert_eq!(session.editor.lines(), ["Xsource"]);
fixture.file("release", "");
drain_until(&mut session, |session| {
session.editor.command_output().is_some()
});
let output = session.editor.command_output().unwrap();
assert!(output.title.contains("exit 7"));
let text = output.content.lines.join("\n");
assert!(text.contains(fixture.0.to_str().unwrap()));
assert!(text.ends_with("out\n[stderr]\nerr"), "{text}");
assert_eq!(session.editor.shell_label(), "");
assert_eq!(session.editor.mode(), Mode::Insert);
session.handle(Input::Char('Z'));
assert_eq!(session.editor.lines(), ["Xsource"]);
session.handle(Input::Escape);
session.handle(Input::Char('Y'));
assert_eq!(session.editor.lines(), ["XYsource"]);
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"source"
);
}
#[cfg(unix)]
#[test]
fn shell_commands_reject_empty_input_and_show_empty_success_without_editing() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "source");
let mut session = fixture.open("");
session.execute(":!");
assert_eq!(session.editor.message(), "Shell command is empty");
assert!(session.shell.is_empty());
session.execute(":!true");
drain_until(&mut session, |session| {
session.editor.command_output().is_some()
});
let output = session.editor.command_output().unwrap();
assert_eq!(output.content.lines, ["(no output)"]);
assert!(output.title.contains("exit 0"));
session.handle(Input::Enter);
assert!(session.editor.command_output().is_none());
assert_eq!(session.editor.lines(), ["source"]);
assert!(!session.editor.dirty());
}
#[test] #[test]
fn oversized_repository_text_skips_snapshots_and_recovers_after_shrinking() { fn oversized_repository_text_skips_snapshots_and_recovers_after_shrinking() {
let fixture = Fixture::new(); let fixture = Fixture::new();
@@ -430,6 +556,7 @@ fn checkout_diff_polling_uses_injected_interval_independently_of_endpoint_follow
id, id,
Ok(DiffOutcome::Catalog(Catalog { Ok(DiffOutcome::Catalog(Catalog {
kind: RepoKind::Git, kind: RepoKind::Git,
graph: Default::default(),
notice: None, notice: None,
entries: [ entries: [
RevisionTarget::Working, RevisionTarget::Working,
@@ -462,6 +589,7 @@ fn checkout_diff_polling_uses_injected_interval_independently_of_endpoint_follow
session.editor.apply_diff_result( session.editor.apply_diff_result(
id, id,
Ok(DiffOutcome::Comparison(Comparison { Ok(DiffOutcome::Comparison(Comparison {
blame: Default::default(),
revisions: ["base".into(), "after".into()], revisions: ["base".into(), "after".into()],
root: fixture.0.clone(), root: fixture.0.clone(),
before: "base".into(), before: "base".into(),
@@ -1354,6 +1482,23 @@ end
assert_eq!(session.editor().lines(), &["a!bc"]); assert_eq!(session.editor().lines(), &["a!bc"]);
} }
#[test]
fn configured_pull_request_symbols_reach_editor() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "abc");
let session = fixture.open(
r#"Vamp.configure do |config|
config.pull_request_review_requested_symbol = "👩‍🚀 review"
config.pull_request_approved_symbol = ""
end"#,
);
assert_eq!(
session.editor().pull_request_review_requested_symbol,
"👩‍🚀 review"
);
assert_eq!(session.editor().pull_request_approved_symbol, "");
}
#[test] #[test]
fn diff_display_preferences_survive_reopening() { fn diff_display_preferences_survive_reopening() {
use crate::{diff::ViewMode, panes::SplitAxis}; use crate::{diff::ViewMode, panes::SplitAxis};
@@ -3581,6 +3726,7 @@ fn async_responses_edit_only_current_bound_working_after_and_refresh_projection(
id, id,
Ok(DiffOutcome::Catalog(Catalog { Ok(DiffOutcome::Catalog(Catalog {
kind: RepoKind::Git, kind: RepoKind::Git,
graph: Default::default(),
notice: None, notice: None,
entries: vec![ entries: vec![
revision(RevisionTarget::Commit("base".into()), "base"), revision(RevisionTarget::Commit("base".into()), "base"),
@@ -3595,12 +3741,15 @@ fn async_responses_edit_only_current_bound_working_after_and_refresh_projection(
session.editor.apply_diff_result( session.editor.apply_diff_result(
id, id,
Ok(DiffOutcome::Comparison(Comparison { Ok(DiffOutcome::Comparison(Comparison {
blame: Default::default(),
revisions: ["base".into(), "working:snapshot".into()], revisions: ["base".into(), "working:snapshot".into()],
root: fixture.0.canonicalize().unwrap(), root: fixture.0.canonicalize().unwrap(),
before: "base".into(), before: "base".into(),
after: "Working".into(), after: "Working".into(),
editable_paths: [PathBuf::from("buffer.txt")].into_iter().collect(), editable_paths: [PathBuf::from("buffer.txt")].into_iter().collect(),
files: vec![ChangedFile { files: vec![ChangedFile {
review_revisions: None,
stats: None,
path: PathBuf::from("buffer.txt"), path: PathBuf::from("buffer.txt"),
old_path: None, old_path: None,
status: FileStatus::Modified, status: FileStatus::Modified,
@@ -3689,3 +3838,161 @@ fn async_responses_edit_only_current_bound_working_after_and_refresh_projection(
session.editor.execute("diff.close"); session.editor.execute("diff.close");
assert!(session.editor.dirty()); assert!(session.editor.dirty());
} }
#[test]
fn applied_pull_request_filters_survive_picker_reopen_but_not_restart() {
use crate::pull_requests::ReviewFilter;
let fixture = Fixture::new();
let path = fixture.0.join("state/pull_requests.json");
let mut session =
EditorSession::open_with_preferences_path(None, vec![], None, Some(Ok(path.clone())))
.unwrap();
assert!(!path.exists());
session.editor.execute("pull_requests");
// Consume the queued listing so menu input cannot start a forge worker.
assert!(session.editor.take_diff_request().is_some());
session.handle(Input::Ctrl('f'));
session.handle(Input::Char(' '));
assert!(!path.exists());
session.handle(Input::Escape);
assert_eq!(
session.editor.pull_request_preferences,
crate::pull_requests::Preferences::default()
);
assert!(!path.exists());
session.handle(Input::Ctrl('f'));
session.handle(Input::Char(' '));
session.handle(Input::Down);
session.handle(Input::Char(' '));
assert!(!path.exists());
session.handle(Input::Enter);
assert!(!path.exists());
assert!(!session.editor.pull_request_preferences_changed);
assert_eq!(
session.editor.pull_request_preferences.filters,
[ReviewFilter::Other]
);
session.handle(Input::Escape);
session.editor.execute("pull_requests");
assert!(session.editor.take_diff_request().is_some());
session.handle(Input::Ctrl('f'));
session.handle(Input::Enter);
assert_eq!(
session.editor.pull_request_preferences.filters,
[ReviewFilter::Other]
);
// A sort edit still persists, without carrying the active filter to disk.
session.handle(Input::Ctrl('s'));
session.handle(Input::Char('1'));
session.handle(Input::Enter);
assert!(path.exists());
let reopened =
EditorSession::open_with_preferences_path(None, vec![], None, Some(Ok(path))).unwrap();
assert_eq!(
reopened.editor.pull_request_preferences.filters,
crate::pull_requests::Preferences::default().filters
);
assert_eq!(reopened.editor.pull_request_configured, [false, false]);
}
#[test]
fn configured_pull_request_sections_replace_only_their_persisted_section() {
use crate::pull_requests::{Direction, Preferences, ReviewFilter, SortField, SortRule};
let fixture = Fixture::new();
let path = fixture.0.join("state/pull_requests.json");
let (store, mut preferences, _) =
crate::pull_request_preferences::Store::load(Ok(path.clone()));
preferences.filters = vec![ReviewFilter::Other];
preferences.sort.insert(
SortField::UpdatedAt,
SortRule {
precedence: Some(1),
direction: Direction::Asc,
},
);
store.save(&preferences).unwrap();
let bytes = fs::read(&path).unwrap();
for (configuration, expected, configured) in [
(
"config.pull_request_sort = {}",
Preferences {
sort: Default::default(),
filters: Preferences::default().filters,
},
[true, false],
),
(
"config.pull_request_filters = []",
Preferences {
sort: preferences.sort.clone(),
filters: vec![],
},
[false, true],
),
(
"config.pull_request_sort = {created_at: {direction: :asc}}; config.pull_request_filters = nil",
Preferences {
sort: [(
SortField::CreatedAt,
SortRule {
precedence: None,
direction: Direction::Asc,
},
)]
.into(),
filters: Preferences::default().filters,
},
[true, false],
),
] {
let config = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| {configuration} }}"),
);
let session = EditorSession::open_with_preferences_path(
None,
vec![],
Some(&config),
Some(Ok(path.clone())),
)
.unwrap();
assert_eq!(session.editor.pull_request_preferences, expected);
assert_eq!(session.editor.pull_request_configured, configured);
assert_eq!(fs::read(&path).unwrap(), bytes);
}
}
#[test]
fn corrupt_pull_request_storage_warns_and_apply_remains_session_only() {
let fixture = Fixture::new();
let path = fixture.file("pull_requests.json", "preserve invalid bytes");
let mut session =
EditorSession::open_with_preferences_path(None, vec![], None, Some(Ok(path.clone())))
.unwrap();
assert!(session.editor.message().contains("session-only"));
session.editor.execute("pull_requests");
assert!(session.editor.take_diff_request().is_some());
session.handle(Input::Ctrl('s'));
session.handle(Input::Char('1'));
session.handle(Input::Enter);
assert!(session.editor.message().contains("preserved"));
assert_eq!(fs::read_to_string(path).unwrap(), "preserve invalid bytes");
}
#[test]
fn failed_pull_request_save_keeps_applied_session_preferences_and_reports_failure() {
let fixture = Fixture::new();
let parent = fixture.0.join("state");
let path = parent.join("pull_requests.json");
let mut session =
EditorSession::open_with_preferences_path(None, vec![], None, Some(Ok(path))).unwrap();
fs::write(&parent, "do not replace").unwrap();
session.editor.execute("pull_requests");
assert!(session.editor.take_diff_request().is_some());
session.handle(Input::Ctrl('s'));
session.handle(Input::Char('1'));
session.handle(Input::Enter);
assert!(session.editor.message().contains("session-only"));
assert!(!session.editor.pull_request_preferences_changed);
assert_eq!(fs::read_to_string(parent).unwrap(), "do not replace");
}
+177
View File
@@ -0,0 +1,177 @@
use super::*;
use std::{
fs,
path::Path,
sync::atomic::{AtomicU64, Ordering},
time::Instant,
};
static NEXT_ROOT: AtomicU64 = AtomicU64::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
loop {
let root = std::env::temp_dir().join(format!(
"vamp-shell-{}-{}",
std::process::id(),
NEXT_ROOT.fetch_add(1, Ordering::Relaxed)
));
match fs::create_dir(&root) {
Ok(()) => return Self(fs::canonicalize(root).unwrap()),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => panic!("Cannot create shell fixture: {error}"),
}
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn start(command: &str, root: &Path) -> Worker {
Worker::start_with_shell(command.into(), root.into(), "/bin/sh".into()).unwrap()
}
fn wait_for<T>(mut ready: impl FnMut() -> Option<T>) -> T {
// File gates determine ordering; this deadline only bounds a stuck test.
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if let Some(value) = ready() {
return value;
}
assert!(Instant::now() < deadline, "Shell fixture did not progress");
thread::sleep(Duration::from_millis(1));
}
}
fn process_running(pid: &str) -> bool {
let output = Command::new("/bin/ps")
.args(["-o", "stat=", "-p", pid])
.output()
.unwrap();
let state = String::from_utf8(output.stdout).unwrap();
let state = state.trim();
// An orphan may await the system reaper, but a zombie cannot keep running
// or retain the inherited output descriptors.
!state.is_empty() && !state.starts_with('Z')
}
#[test]
fn shell_is_pending_until_gate_and_preserves_cwd_streams_status_and_null_stdin() {
let fixture = Fixture::new();
let mut worker = start(
"printf '%s\\n' \"$PWD\"; : > ready; \
while [ ! -e release ]; do :; done; \
if read value; then exit 99; fi; \
printf 'out\\000bytes'; printf 'err\\000bytes' >&2; exit 7",
&fixture.0,
);
wait_for(|| fixture.0.join("ready").exists().then_some(()));
assert!(worker.poll().is_none());
fs::write(fixture.0.join("release"), b"").unwrap();
let output = wait_for(|| worker.poll()).unwrap();
let mut expected = fixture.0.as_os_str().as_encoded_bytes().to_vec();
expected.extend_from_slice(b"\nout\0bytes");
assert_eq!(output.stdout, expected);
assert_eq!(output.stderr, b"err\0bytes");
assert_eq!(output.code, Some(7));
assert!(worker.poll().is_none());
}
#[test]
fn shell_reports_spawn_failure() {
let fixture = Fixture::new();
let mut worker = start("exit 0", &fixture.0.join("missing"));
let error = wait_for(|| worker.poll()).unwrap_err();
assert!(error.starts_with("Cannot start shell command:"), "{error}");
}
#[test]
fn disconnected_shell_worker_reports_one_failure() {
let (cancel, _cancelled) = mpsc::channel();
let (send, results) = mpsc::channel();
drop(send);
let mut worker = Worker {
cancel,
results,
thread: None,
finished: false,
};
assert_eq!(
worker.poll().unwrap().unwrap_err(),
"Shell worker stopped unexpectedly"
);
assert!(worker.poll().is_none());
}
#[test]
fn shell_drains_both_streams_concurrently_with_explicit_bounded_capture() {
let fixture = Fixture::new();
let mut worker = start(
"(dd if=/dev/zero bs=1024 count=4097 2>/dev/null) & \
(dd if=/dev/zero bs=1024 count=4097 2>/dev/null) >&2 & wait",
&fixture.0,
);
let output = wait_for(|| worker.poll()).unwrap();
assert_eq!(output.code, Some(0));
for bytes in [output.stdout, output.stderr] {
assert_eq!(bytes.len(), OUTPUT_LIMIT + TRUNCATION_NOTE.len());
assert!(bytes[..OUTPUT_LIMIT].iter().all(|byte| *byte == 0));
assert_eq!(&bytes[OUTPUT_LIMIT..], TRUNCATION_NOTE);
}
}
#[test]
fn dropping_shell_kills_and_reaps_group_with_pipe_inheriting_descendant() {
let fixture = Fixture::new();
let worker = start(
"/bin/sh -c 'printf \"%s\" \"$$\" > descendant; : > ready; \
while [ ! -e release ]; do :; done' & \
printf '%s' \"$$\" > leader; wait",
&fixture.0,
);
wait_for(|| fixture.0.join("ready").exists().then_some(()));
let descendant = fs::read_to_string(fixture.0.join("descendant")).unwrap();
let leader = wait_for(|| {
fs::read_to_string(fixture.0.join("leader"))
.ok()
.filter(|pid| !pid.is_empty())
});
let (done, dropped) = mpsc::channel();
let dropper = thread::spawn(move || {
drop(worker);
done.send(()).unwrap();
});
dropped.recv_timeout(Duration::from_secs(10)).unwrap();
dropper.join().unwrap();
wait_for(|| (!process_running(&descendant)).then_some(()));
let leader_status = Command::new("/bin/ps")
.args(["-o", "stat=", "-p", &leader])
.output()
.unwrap();
assert!(
leader_status.stdout.is_empty(),
"Shell leader was not reaped"
);
}
#[test]
fn completed_shell_does_not_leave_background_jobs_holding_output_open() {
let fixture = Fixture::new();
let mut worker = start(
"/bin/sh -c 'printf \"%s\" \"$$\" > descendant; : > ready; \
while [ ! -e release ]; do :; done' & \
while [ ! -e ready ]; do :; done; printf done",
&fixture.0,
);
let output = wait_for(|| worker.poll()).unwrap();
assert_eq!(output.code, Some(0));
assert_eq!(output.stdout, b"done");
let descendant = fs::read_to_string(fixture.0.join("descendant")).unwrap();
wait_for(|| (!process_running(&descendant)).then_some(()));
}
+34
View File
@@ -648,6 +648,40 @@ fn honey_highlights_declarations_literals_edits_and_markdown_fences() {
} }
} }
#[test]
fn typst_highlights_markup_code_edits_and_fenced_languages() {
let source = "= Heading\n#let message = \"hello é\"\n#let count = 42\n\
#text(message) // note\n*strong* and _emphasis_\n\
```rust\nfn main() { let n = 7; }\n```";
let mut cache = self::cache(source, "main.typ");
assert_eq!(cache.language(), Some("Typst"));
assert!(!cache.tree.as_ref().unwrap().root_node().has_error());
for (row, token, kind) in [
(0, "Heading", SyntaxKind::Markup),
(1, "let", SyntaxKind::Keyword),
(1, "hello é", SyntaxKind::String),
(2, "42", SyntaxKind::Number),
(3, "text", SyntaxKind::Function),
(3, "// note", SyntaxKind::Comment),
(4, "strong", SyntaxKind::Markup),
(4, "emphasis", SyntaxKind::Markup),
(6, "fn", SyntaxKind::Keyword),
(6, "7", SyntaxKind::Number),
] {
assert_token(&cache, source, row, token, kind);
}
let edited = source.replace("\"hello é\"", "false");
cache.update(&lines(&edited), Some(Path::new("main.typ")), 1);
assert_token(&cache, &edited, 1, "false", SyntaxKind::Keyword);
assert_fresh(&cache, &edited, "main.typ");
for tag in ["typst", "typ"] {
let markdown = format!("```{tag}\n#let count = 42\n```");
let injected = self::cache(&markdown, "README.md");
assert_token(&injected, &markdown, 1, "let", SyntaxKind::Keyword);
assert_token(&injected, &markdown, 1, "42", SyntaxKind::Number);
}
}
#[test] #[test]
fn filenames_shebang_save_as_and_tsx_use_actual_grammar_identity() { fn filenames_shebang_save_as_and_tsx_use_actual_grammar_identity() {
let source = lines("\u{feff}#!/usr/bin/env python3\nreturn 42"); let source = lines("\u{feff}#!/usr/bin/env python3\nreturn 42");
+2
View File
@@ -22,6 +22,8 @@ fn selected_files_and_auxiliary_grammars_are_registered() {
("main.C", "C++"), ("main.C", "C++"),
("main.go", "Go"), ("main.go", "Go"),
("main.zig", "Zig"), ("main.zig", "Zig"),
("main.typ", "Typst"),
("main.TYP", "Typst"),
] { ] {
assert_eq!( assert_eq!(
detect(Some(Path::new(filename)), None).map(|spec| spec.name), detect(Some(Path::new(filename)), None).map(|spec| spec.name),
+1580 -302
View File
File diff suppressed because it is too large Load Diff