Compare commits

...

111 Commits

Author SHA1 Message Date
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
hl-valdemar d8be35e3bd fix: route normal-mode cuts through clipboard pipeline 2026-10-17 00:50:00 +02:00
hl-valdemar b9f29d258c fix: support large files in comparisons
Replace the 16 MiB comparison rejection with independent bounded per-side loading and frozen Working blobs. Bound diff computation, defer large inline highlighting, index source-row navigation, and expose actual load failures. Verified the real 52.3 MB parser through repository/session APIs and terminal rendering with EOF/start navigation.
2026-10-17 00:46:15 +02:00
hl-valdemar adbd2c3f81 fix: keep large-file editing responsive
Bound synchronous full-source syntax parsing, cache dirty comparisons, edit ordinary inline changes in place, retain changed-row undo history, and buffer atomic saves. Skip repository snapshots beyond existing worker limits before copying their text.
2026-10-16 21:16:53 +02:00
hl-valdemar 02d1422a04 feat: add Honey syntax highlighting 2026-10-16 20:51:17 +02:00
Valdemar Lorenzen 67b42c0d93 docs: prohibit TODO.md revision description suffixes 2026-10-16 18:53:09 +02:00
Valdemar Lorenzen 14abdfd2cf refactor: remove unused review state and duplicate storage reads 2026-10-16 18:53:03 +02:00
Valdemar Lorenzen 66250d6c40 refactor: encapsulate diff document drawing on WheelPanels 2026-10-16 18:53:03 +02:00
Valdemar Lorenzen befa432095 refactor: encapsulate review identity changes 2026-10-16 18:53:02 +02:00
Valdemar Lorenzen 8174e81c5c refactor: share successful review save acknowledgement 2026-10-16 18:53:02 +02:00
Valdemar Lorenzen 4511bc2df9 refactor: hide logical comparison rows from painting 2026-10-16 18:53:02 +02:00
Valdemar Lorenzen a1fdc1e1b0 refactor: deepen conflict History alignment ownership 2026-10-16 18:53:02 +02:00
Valdemar Lorenzen 53c37e6209 docs: keep Rust tests under tests directory 2026-10-16 18:53:02 +02:00
Valdemar Lorenzen 2772e6878a refactor: move embedded tests into dedicated test directory 2026-10-16 18:53:02 +02:00
Valdemar Lorenzen de6a57e67d feat: add configurable three-pane conflict views 2026-10-16 18:53:01 +02:00
Valdemar Lorenzen a6310594c5 feat: expose read-only local review CLI 2026-10-16 18:53:01 +02:00
Valdemar Lorenzen 6ae8bf06a8 feat: resolve local review comments manually 2026-10-16 18:53:01 +02:00
Valdemar Lorenzen b558f82094 feat: persist local reviews automatically in SQLite 2026-10-16 18:53:01 +02:00
Valdemar Lorenzen ef1c377167 feat: store local reviews transactionally in SQLite 2026-10-16 18:53:00 +02:00
Valdemar Lorenzen 0c7abc17fe docs: establish pre-alpha clean-cutover policy 2026-10-16 18:53:00 +02:00
Valdemar Lorenzen 5b8f8c3f07 refactor: remove legacy review JSON migration 2026-10-16 18:52:59 +02:00
Valdemar Lorenzen 2259d61633 feat: anchor review comments to precise text ranges 2026-10-16 18:52:59 +02:00
Valdemar Lorenzen 9a24302b4e docs: plan precise text-range review anchors 2026-10-16 18:52:59 +02:00
Valdemar Lorenzen 2376a37e5d docs: specify SQLite-backed local review persistence 2026-10-16 18:52:58 +02:00
Valdemar Lorenzen 4cc0453344 fix: retain selections and float local review editors 2026-10-16 18:52:58 +02:00
Valdemar Lorenzen d67b874b87 fix: preserve Visual selections through command entry 2026-10-16 18:52:57 +02:00
Valdemar Lorenzen 0446ec86c7 feat: add local diff review workflow 2026-10-16 18:52:57 +02:00
Valdemar Lorenzen 930735c537 fix: distinguish Ctrl+I from Tab in comparisons 2026-10-16 18:52:57 +02:00
Valdemar Lorenzen 92e1e6e8fd docs: split local review UX from GitHub publishing 2026-10-16 18:52:57 +02:00
Valdemar Lorenzen a664b807b3 feat: show line change counts in diff tabs 2026-10-16 18:52:56 +02:00
Valdemar Lorenzen c689d9e782 fix: retain numbered declarations in reserved sticky rows 2026-10-16 18:52:56 +02:00
Valdemar Lorenzen 7b950210b8 fix: preserve visible declaration names under scope overlays 2026-10-16 18:52:55 +02:00
Valdemar Lorenzen d654522ad4 fix: hide clipped scope labels at the pane top 2026-10-16 18:52:55 +02:00
Valdemar Lorenzen 682427324d fix: overlay clipped scope labels without reflow 2026-10-16 18:52:54 +02:00
Valdemar Lorenzen e37fa509c9 feat: reuse fuzzy search in the file picker 2026-10-16 18:52:53 +02:00
Valdemar Lorenzen d1048df345 refactor: name configurable viewport scroll steps accurately 2026-10-16 18:52:53 +02:00
Valdemar Lorenzen 284ddd3713 feat: configure half-page viewport scroll distance 2026-10-16 18:52:52 +02:00
Valdemar Lorenzen 5a8c37b2f2 fix: coalesce rapid viewport scrolling redraws 2026-10-16 18:52:51 +02:00
Valdemar Lorenzen 08fdf2bd0f feat: scroll viewport independently of the cursor 2026-10-16 18:52:50 +02:00
Valdemar Lorenzen 60a12110fa perf: bound diff label projection during caret scrolling
The label lag came from repeatedly projecting full viewports while walking backwards from the caret. Skip only candidates whose maximum possible source/wrap and label height cannot reach the target, then retain the original first-target search. Reuse already resolved scope contexts. The conservative bound preserves non-monotonic unified declaration visibility and independent sticky labels. Added regressions for split declaration-visibility ties and unified height changes; architecture updated. Verification: 10,176 differential projections matched; all 964 release tests pass; release binary built. Reduced repro p95: 3.9572ms -> 1.4467ms. Live 160x100 function-dense 360-key burst median catch-up: 22.895ms -> 9.611ms, with final cursor/viewport asserted. Full and no-dependency Clippy checks are blocked by existing repository warnings, including vendored Crossterm, blame, LSP, Markdown, syntax and existing TUI code. Temporary harnesses removed. No remote changes.
2026-10-16 18:52:50 +02:00
Valdemar Lorenzen 1df9e66cd5 fix: retain semantic colors across editable diff updates 2026-10-16 18:52:50 +02:00
Valdemar Lorenzen a3be494d57 fix: serialize absent jj bookmark targets as null 2026-10-16 18:52:49 +02:00
Valdemar Lorenzen c32d4a7590 feat: refresh diffs when selected jj bookmarks move 2026-10-16 18:52:49 +02:00
Valdemar Lorenzen 1a02502488 feat: mark fully checked collapsed diff folders 2026-10-16 18:52:48 +02:00
Valdemar Lorenzen a93afc7d16 fix: hide diff scope labels for visible declarations
The visible declaration already provides scope context. Cache the actual
identifier point separately from the declaration extent/decorators and hide
labels when it is visible in that side of the projected viewport.

Refill reclaimed rows and anchor carets against the resulting layout.
Retain sticky context when the symbol scrolls or wraps out of view.

Verified: 951 tests passed, cargo build, and real PTY smoke for declaration
visibility/scroll restoration, wrapping, horizontal clipping, unified view,
and short horizontal-pane caret reflow.
2026-10-16 18:52:48 +02:00
Valdemar Lorenzen 39fd3a669b fix: avoid repeated scope labels within diff excerpts
Compare each side with its preceding source row within the same excerpt,
not the adjacent aligned pair: alignment holes must not restart labels.
Retain sticky context independently on each side, including leading holes.

Regression: merged Python insert/delete hunks emitted five labels instead
of one. Verify deduplication, folded excerpt restart, and sticky-gap context.

Verified: cargo test (950 passed), cargo build, and real PTY smoke covering
split/unified, wrap, sticky gaps, and a short horizontal pane source caret.
2026-10-16 18:52:47 +02:00
Valdemar Lorenzen a43094dda3 feat: show diff hunk scope labels
Show per-side Tree-sitter scope paths inline and as sticky continuation labels.
Keep presentation rows out of source coordinates, selections, and edits.

Verified: cargo test (949 passed), cargo build, and real PTY smoke covering
split/unified, wrapping, both split axes, sticky scrolling, live rename and undo.

Size exception: 549 implementation lines added plus deleted, excluding tests
and docs. Keep syntax extraction and coordinate-safe rendering together as one
working behavior.
2026-10-16 18:52:46 +02:00
Valdemar Lorenzen 75dc31b66f fix: avoid indentation-only wrapped rows
The shared wrapper treated leading whitespace as a usable word boundary,
pushing a long first token onto a continuation and leaving a numbered blank row.
Only record boundaries after non-whitespace content on the current visual row.
Preserve source whitespace, tab stops, caret rows and paired diff alignment.

Verified: 945 tests, formatting, and live ordinary/split-diff fixture smoke,
including wrap off/on and aligned closing brackets.
2026-10-16 18:52:46 +02:00
Valdemar Lorenzen 16490942d6 fix: coalesce fragmented word-diff highlights 2026-10-16 18:52:45 +02:00
Valdemar Lorenzen 3a7e2074b4 fix: move word search activation onto its match 2026-10-16 18:52:45 +02:00
Valdemar Lorenzen f5c6d098f8 fix: retain current occurrence when submitting search 2026-10-16 18:52:44 +02:00
Valdemar Lorenzen adc44d22db fix: clear search highlighting on Normal escape 2026-10-16 18:52:44 +02:00
Valdemar Lorenzen de62467208 fix: highlight all search matches before word navigation 2026-10-15 12:16:14 +02:00
Valdemar Lorenzen 7b3b3866fd feat: add Vim-style word search 2026-10-15 12:12:11 +02:00
Valdemar Lorenzen 2fd240aca8 feat: add Zig syntax highlighting 2026-10-15 11:56:54 +02:00
Valdemar Lorenzen 08db1801eb fix: increase LSP hover window height 2026-10-15 11:53:07 +02:00
Valdemar Lorenzen 5396968b08 fix: clarify conflict pane roles and status indicators
Use Resolution and Other version borders, omit branch/path metadata,
and show unsaved output state only on the writable pane. Track native
explorer conflict C from authoritative jj state across draft/check/error
transitions and clear it only after confirmed resolution.

Focused resolver and save tests pass; live terminal labels and explorer
verified without saving the mock conflicts. Full library run: 795 pass,
one unrelated async plugin fixture_barrier startup failure.
2026-10-15 11:19:50 +02:00
Valdemar Lorenzen 168e12160a refactor: route conflict resolution through the diff workspace
Use the active DiffState, native FilePicker and existing diff input,
layout, focus, source navigation and scrolling paths for Resolution.
Delete separate resolver filtering, file-list rendering and scroll APIs.
Keep projections, source choices, History and guarded jj saves distinct.

Verify native parity for picker remaps, current-file selection, excerpt
initialization, folded search, wheel focus, layouts, buffer transitions
and per-file viewports. All 932 tests pass; actual terminal smoke covers
Space-e explorer, Down/Enter file selection and guarded confirmed save.
Clippy completes with existing warnings only.

Size exception: 1197 implementation added/deleted lines excluding tests
and docs; input routing, native buffer lifecycle and rendering are kept
as one coherent cutover, without a parallel legacy resolver path.
2026-10-15 10:21:10 +02:00
hl-valdemar 4aff22377f fix: focus After when opening a diff file 2026-10-14 21:47:23 +02:00
Valdemar Lorenzen ef4c38d8a7 feat: resolve conflicts through focused two-way drafts
Replace the marker-filled three-pane resolver with a marker-free native
Draft versus frozen Alternative projected from current working text.
Preserve preexisting outside edits, native undo, dirty-save guards, all
sides/bases, and draft caret/viewports across separate History views.
Reuse DiffState alignment/highlighting/excerpts for Resolution and merge
its base-aligned rows for readonly three-way History. Hide explorer and
source-selector chrome by default.

Verified 926 tests plus actual terminal projection, History highlighting,
hunk choice, optional explorer, and guarded jj-confirmed save workflows.
Clippy completes with pre-existing warnings only. Smoke fixtures stay
unresolved in this ancestry; remove that ancestry before publication.

Size exception: approximately 1650 implementation added/deleted lines,
excluding tests/docs, kept coherent because native draft lifecycle and
aligned rendering must change together without parallel legacy modes.
2026-10-14 17:01:45 +02:00
Valdemar Lorenzen f2d505b952 test: add conflict resolver smoke fixtures 2026-10-14 16:17:22 +02:00
Valdemar Lorenzen a3aef713e2 feat: resolve current jj working-copy conflicts
Add a dedicated native resolver workspace with all side/base inputs,
shared editable Results, region choices, retained per-file status,
responsive rendering, and explicit nontext file choices.

Guard writes with jj working-copy locking, frozen checkout identity,
exact disk baselines, native dirty-buffer protection, and asynchronous
jj snapshot/status confirmation. Historical comparisons remain readonly.

Verified all 916 tests and live isolated terminal workflows covering text
regions, narrow layouts, binary bytes and symlink targets/types.
Clippy completes with pre-existing warnings only.

Size exception: 2614 implementation additions/deletions excluding tests
and docs. Native workspace integration, explicit file choices and the
safe save/check operation are retained as one complete behavioral slice.
2026-10-14 16:04:15 +02:00
Valdemar Lorenzen dcfa50319c fix: preserve lazy snapshots for conflicted jj revisions
Conflicted revisions read every file through jj file show, which returns
empty output for symlinks and causes repository-wide subprocess fanout.
Keep ordinary Git blob sources and immutable identities; render only
selected conflicted paths through jj.

Verified all 905 tests and the original local bookmark comparison with
25 correct paths in 0.522s, plus actual terminal conflict viewing.
Strict Clippy is blocked by existing diagnostics outside this change.
2026-10-14 15:49:26 +02:00
Valdemar Lorenzen f26237af78 perf: batch queued cursor navigation events 2026-10-14 15:32:52 +02:00
Valdemar Lorenzen 1cb9a0ddfc fix: retain repository status between picker openings 2026-10-14 14:56:55 +02:00
Valdemar Lorenzen 77a9111f45 fix: align diff picker review checkmarks 2026-10-14 14:36:15 +02:00
Valdemar Lorenzen 6b27fab075 fix: read macOS clipboard without subprocess startup 2026-10-14 14:26:22 +02:00
Valdemar Lorenzen 0000089f42 feat: stream newest pull requests by category 2026-10-14 14:26:22 +02:00
Valdemar Lorenzen 5243de66fe fix: resolve SSH aliases for forge discovery 2026-10-14 13:51:49 +02:00
Valdemar Lorenzen af6496bd5a feat: browse GitHub GitLab and Gitea pull requests 2026-10-14 13:40:43 +02:00
Valdemar Lorenzen 15968a328c fix: preserve diff backgrounds and align file markers 2026-10-14 13:40:41 +02:00
Valdemar Lorenzen 8a1e98819c fix: support mutable language tab configuration 2026-10-14 13:13:01 +02:00
Valdemar Lorenzen c5f80f27a6 fix: replace selected ranges instead of ignoring visual paste 2026-10-14 13:13:01 +02:00
Valdemar Lorenzen 5580d6fbd4 refactor: share language IDs with indentation configuration 2026-10-14 13:12:50 +02:00
Valdemar Lorenzen 6a43fb9e0c feat: configure spaces on Tab per language 2026-10-14 13:12:46 +02:00
hl-valdemar 86718221c4 docs: update todo and backup current config.rb 2026-10-14 00:27:54 +02:00
93 changed files with 1593939 additions and 40109 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
+15
View File
@@ -0,0 +1,15 @@
# Revision descriptions
Use concise revision descriptions without a `(TODO.md)` suffix. Include a ticket ID only when the task has a real ticket.
# Compatibility
Vamp is a single-user, pre-alpha tool. Prefer clean cutovers to the simplest current design across code, configuration, interfaces, and persisted formats. Update callers and remove obsolete paths together; add backwards compatibility or migrations only when explicitly requested.
Report incompatible persisted data clearly and preserve it unless the user explicitly requests a reset or deletion.
# Tests
Keep project tests and test-only fixtures/helpers under `tests/`. `src/` may retain test-module declarations and minimal test hooks; keep test bodies outside implementation files and preserve private access rather than widening production APIs.
When adding or moving tests, follow [test organization](docs/development.md#test-organization) for module wiring and verification.
+3
View File
@@ -29,6 +29,9 @@ The full command name of a command-capable core action, without its `core.` pref
**Command alias**: **Command alias**:
A shorthand spelling of a canonical Ex command, such as `q` for `quit`. An alias refers to the same action, not a separate command implementation. A shorthand spelling of a canonical Ex command, such as `q` for `quit`. An alias refers to the same action, not a separate command implementation.
**Conflict History**:
A read-only comparison of selected original conflict inputs against a shared base, separate from the editable resolution Draft.
## Configuration interfaces ## Configuration interfaces
- Prefer typed objects for editor-owned actions, modes, and other closed sets. Expose new core binding targets through `config.core` rather than requiring command-name strings. - Prefer typed objects for editor-owned actions, modes, and other closed sets. Expose new core binding targets through `config.core` rather than requiring command-name strings.
Generated
+113
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"
@@ -385,6 +395,18 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]] [[package]]
name = "fancy-regex" name = "fancy-regex"
version = "0.11.0" version = "0.11.0"
@@ -539,6 +561,15 @@ dependencies = [
"foldhash", "foldhash",
] ]
[[package]]
name = "hashlink"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
dependencies = [
"hashbrown 0.17.1",
]
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@@ -666,6 +697,17 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libsqlite3-sys"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "line-clipping" name = "line-clipping"
version = "0.3.8" version = "0.3.8"
@@ -1009,6 +1051,12 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.15.0" version = "1.15.0"
@@ -1215,6 +1263,31 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rsqlite-vfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.21",
]
[[package]]
name = "rusqlite"
version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [
"bitflags 2.13.2",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -1427,6 +1500,18 @@ version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]] [[package]]
name = "stacker" name = "stacker"
version = "0.1.25" version = "0.1.25"
@@ -1731,6 +1816,14 @@ dependencies = [
"tree-sitter-language", "tree-sitter-language",
] ]
[[package]]
name = "tree-sitter-honey"
version = "0.1.0"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]] [[package]]
name = "tree-sitter-html" name = "tree-sitter-html"
version = "0.23.2" version = "0.23.2"
@@ -1847,6 +1940,16 @@ dependencies = [
"tree-sitter-language", "tree-sitter-language",
] ]
[[package]]
name = "tree-sitter-zig"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab11fc124851b0db4dd5e55983bbd9631192e93238389dcd44521715e5d53e28"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"
@@ -1910,9 +2013,11 @@ 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",
"rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"similar", "similar",
@@ -1922,6 +2027,7 @@ dependencies = [
"tree-sitter-cpp", "tree-sitter-cpp",
"tree-sitter-css", "tree-sitter-css",
"tree-sitter-go", "tree-sitter-go",
"tree-sitter-honey",
"tree-sitter-html", "tree-sitter-html",
"tree-sitter-javascript", "tree-sitter-javascript",
"tree-sitter-json", "tree-sitter-json",
@@ -1933,10 +2039,17 @@ dependencies = [
"tree-sitter-toml-ng", "tree-sitter-toml-ng",
"tree-sitter-typescript", "tree-sitter-typescript",
"tree-sitter-yaml", "tree-sitter-yaml",
"tree-sitter-zig",
"unicode-segmentation", "unicode-segmentation",
"unicode-width", "unicode-width",
] ]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
+4
View File
@@ -7,6 +7,7 @@ edition = "2024"
crossterm = "0.29" crossterm = "0.29"
ignore = "0.4" ignore = "0.4"
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "underline-color", "unstable-backend-writer", "unstable-rendered-line-info"] } ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "underline-color", "unstable-backend-writer", "unstable-rendered-line-info"] }
rusqlite = { version = "0.40.2", features = ["bundled"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
similar = { version = "2", default-features = false } similar = { version = "2", default-features = false }
@@ -17,6 +18,7 @@ tree-sitter-cpp = "0.23.4"
tree-sitter-css = "0.25.0" tree-sitter-css = "0.25.0"
tree-sitter-go = "0.25.0" tree-sitter-go = "0.25.0"
tree-sitter-html = "0.23.2" tree-sitter-html = "0.23.2"
tree-sitter-honey = { path = "vendor/tree-sitter-honey" }
tree-sitter-javascript = "0.25.0" tree-sitter-javascript = "0.25.0"
tree-sitter-json = "0.24.8" tree-sitter-json = "0.24.8"
tree-sitter-make = "1.1.1" tree-sitter-make = "1.1.1"
@@ -25,8 +27,10 @@ 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"
unicode-segmentation = "1.12" unicode-segmentation = "1.12"
unicode-width = "0.2" unicode-width = "0.2"
+22
View File
@@ -13,6 +13,26 @@ 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.
### Manage local reviews without opening the editor
```sh
vamp --review list --repo .
vamp --review list --repo . --json
vamp --review show 1 --repo . --json
vamp --review show 1 --repo . --all
vamp --review delete 1 3 --repo . # Delete comment 3 from review 1
```
The review CLI discovers the Git/Jujutsu checkout from `--repo` (default: current directory). It uses the same default configuration, `--config PATH`, `--no-config`, and `review_directory` as the editor, but starts no editor workers or terminal UI. Missing storage lists no reviews without creating a database; incompatible storage is rejected and preserved.
Use the persistent **Review ID** printed by `list`, not a list position. IDs are shared across repositories; listing filters them to the selected checkout, so its first review need not be ID 1. Human-readable listings include a command to show each review. The internal save revision is omitted from text but remains in JSON; it is not a Git/Jujutsu revision. `show all` is not supported: use `show ID --all` to include resolved comments in that review.
`show` includes unresolved comments by default; `--all` includes resolved comments too. Human-readable output is the default. JSON includes the canonical repository root, storage review ID/revision, summary/verdict, total/resolved/unresolved counts, and comments with exact zero-based, end-exclusive grapheme anchors and selected source excerpts—not whole source files. `list --json` returns the repository and review metadata/counts without comments. Explicit editor JSON exports still contain complete anchor source text.
`list` and `show` are read-only. `delete REVIEW_ID COMMENT_ID` immediately commits deletion of one resolved or unresolved comment, then prints the updated review with its remaining unresolved comments (`--json` is supported). Both IDs are required; missing reviews/comments and repository mismatches fail without changing stored reviews or creating missing storage. Deleting the last comment keeps the review, summary, and verdict. Concurrent saves use the same revision-conflict protection as the editor; an already-open editor must reload before saving after a CLI deletion. Existing JSON exports are not modified.
`vamp review` still opens a file named `review`; `vamp -- --review` opens a file named `--review`.
## Getting started ## Getting started
Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal. Type `:save` (alias `:w`) and Enter to save, or `:save path` to name an unnamed buffer. Type `:quit` (alias `:q`) and Enter to quit; it refuses unsaved changes in any open buffer. `:force_quit` (alias `:q!`) explicitly discards changes and quits. Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal. Type `:save` (alias `:w`) and Enter to save, or `:save path` to name an unnamed buffer. Type `:quit` (alias `:q`) and Enter to quit; it refuses unsaved changes in any open buffer. `:force_quit` (alias `:q!`) explicitly discards changes and quits.
@@ -34,11 +54,13 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal.
| Search in Normal | `/`, type a literal query, Enter; `n`/`N` for next/previous | | Search in Normal | `/`, type a literal query, Enter; `n`/`N` for next/previous |
| Select/copy/cut | `v` characterwise or `V` whole rows, move to extend, then `y`/`d` in Visual | | Select/copy/cut | `v` characterwise or `V` whole rows, move to extend, then `y`/`d` in Visual |
| Paste in Normal | `p`/`P` after/before the caret or row for linewise fragments; system clipboard by default | | Paste in Normal | `p`/`P` after/before the caret or row for linewise fragments; system clipboard by default |
| Paste over selected text | `p` / `P` in Visual replaces every selection, preserving the clipboard |
| Command palette | Ctrl+p; type to filter, arrows select, Enter accepts, Esc cancels | | Command palette | Ctrl+p; type to filter, arrows select, Enter accepts, Esc cancels |
| Buffer picker in Normal | Space then `b l` or `:buffers`; type to search, arrows select, Enter switches, Esc cancels | | Buffer picker in Normal | Space then `b l` or `:buffers`; type to search, arrows select, Enter switches, Esc cancels |
| Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` | | Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` |
| Repository diff in Normal | `:diff`; choose Before then After, Tab changes category, type to search; Esc closes the selector; Working or checkout-matched pinned After is editable; `:diff.close` exits the comparison | | Repository diff in Normal | `:diff`; choose Before then After, Tab changes category, type to search; Esc closes the selector; Working or checkout-matched pinned After is editable; `:diff.close` exits the comparison |
| Diff layout in Normal | Space then `v` cycles split/unified; Space then `s` flips left/right vs above/below split | | Diff layout in Normal | Space then `v` cycles split/unified; Space then `s` flips left/right vs above/below split |
| Resolve jj working-copy conflicts | `:resolve`; edit marker-free Draft versus Alternative, `:resolve.history` for inputs/base, `:w` to save and confirm with jj |
Space is the default leader: press its suffix keys in order, not simultaneously. The [editing guide](docs/editing.md) contains the complete key table and behavior reference. Space is the default leader: press its suffix keys in order, not simultaneously. The [editing guide](docs/editing.md) contains the complete key table and behavior reference.
+19 -25
View File
@@ -1,29 +1,23 @@
- [x] add option to highlight trailing whitespace (off by default) - [x] feature: syntax/lsp - add a default configuration for typst.
- [x] add option to put characters at certain whitespace characters (off by default) - for the lsp, i'm thinking tinymist is a good candidate.
- whitespace kind -> character map should be configurable. - [ ] feature: diff view - GitHub code review integration after the local review UX is usable.
- [ ] diff view - design question: - publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
- is it possible to angle the hatchings for empty lines more? - preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
- if a character like the current one exists with a 45degree angle instead (and a top and bottom), we could use that across two columns. that would give us a perfectly 45 degree hatching. - 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.
- [ ] github/gitlab/gitea integration (probably requries authentication with github) - define follow-on scope for reading/replying to remote threads, editing/deleting published comments, and resolving threads.
- github pr picker (floating window) - [ ] 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).
- three tabs: open, closed, all - [ ] 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).
- search under the given category - verify that notification-triggered queries return fresh colors in Ghostty through Herdr before removal; retain startup, focus-regain, and notification-driven palette refresh.
- selecting a pr opens the diff viewer for that pr using the branch merge base (standard github diff view on prs; already implemented as default diff viewer behavior in vamp) - [ ] feature: pull a pr description and display it in a buffer:
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
- [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding.
- design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files.
- [ ] 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.
- [ ] 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.
- enforce an OS-level sandbox on the LSP process and every child it spawns, including build scripts and procedural macros. A temporary checkout and read-only editor buffers are not security isolation. - enforce an OS-level sandbox on the LSP process and every child it spawns, including build scripts and procedural macros. A temporary checkout and read-only editor buffers are not security isolation.
- deny access to the live checkout, home directory, credentials, and SSH agent; deny network access by default. Additional access or code-execution permissions require explicit approval. - deny access to the live checkout, home directory, credentials, and SSH agent; deny network access by default. Additional access or code-execution permissions require explicit approval.
- fail closed: if these restrictions cannot be enforced, keep read-only viewing available but do not launch historical LSP analysis; never silently fall back to an unrestricted process. - fail closed: if these restrictions cannot be enforced, keep read-only viewing available but do not launch historical LSP analysis; never silently fall back to an unrestricted process.
- retain the snapshot and analysis environment while historical views/tabs reference it; stop workers and clean up disposable storage after the final reference closes. - retain the snapshot and analysis environment while historical views/tabs reference it; stop workers and clean up disposable storage after the final reference closes.
+53
View File
@@ -0,0 +1,53 @@
Vamp.configure do |config|
config.lsp.merge!(
"rust" => true,
"ruby" => true,
"python" => true,
)
# fallback tab spaces
config.tab_spaces = 4
# language specific tab spaces
config.language_tab_spaces.merge!(
"rust" => 4,
"ruby" => 2,
"makefile" => nil,
)
# 0 (default): scroll only at viewport edges
# nil: center; oversized integer margins also center
config.scroll_distance = 7
config.scroll_step_lines = 3
config.highlight_trailing_whitespace = true
#config.whitespace_scope = "leading" # or "all" (default)
#config.whitespace_characters = {
# " " => "·",
# "\t" => "→",
# "\n" => "↵",
#}
# "bar" (default), "symbols", "line_numbers", "none"
config.git_gutter = "bar"
# diff view
config.diff_context_lines = 10
config.diff_expand_lines = 15
# general bindings
config.bind "leader q", config.core.pane.close
config.bind "leader t w", config.core.word_wrap.toggle
# pane navigation
config.bind "ctrl+w h", config.core.pane.focus_left
config.bind "ctrl+w j", config.core.pane.focus_down
config.bind "ctrl+w k", config.core.pane.focus_up
config.bind "ctrl+w l", config.core.pane.focus_right
# lsp bindings
config.bind "K", config.core.lsp.hover
config.bind "leader d", config.core.lsp.definition
config.bind "leader D", config.core.lsp.diagnostics
end
+34 -8
View File
@@ -11,11 +11,18 @@ 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, persisted file checkmarks, SQLite storage through bundled rusqlite, and atomic JSON import/export with external-change protection. Schema 3 stores review identity, repository, revision, summary, and verdict in `reviews`; `comments` stores ordered comment IDs, bodies, resolution states, and explicit immutable anchor fields. `reviewed_files` stores revision pairs, paths, optional old paths, and optional exact live After text. Foreign keys, unique keys, and checks enforce relationships and scalar invariants. Full `u64` comment IDs use checked decimal text because SQLite integers are signed. No JSON documents are stored in SQLite.
`Store` retains compare-and-swap review revisions and atomic whole-review saves: metadata and replacement comment/checkmark rows commit together. Read transactions reconstruct validated reviews from one consistent snapshot, including when listing in descending ID order. Stored text is bounded to 64 MiB per review, including a fixed per-comment/checkmark overhead. JSON version 4 is an independent import/export format. Incompatible databases, including schemas 1 and 2, are rejected and preserved without migration or reset. CLI metadata, sole-review restoration, and selector labels reuse loaded reviews; selector choices live only in the displayed buffer.
The review CLI keeps `list`/`show` read-only. Comment deletion validates the explicit review/comment IDs through an existing read-only store before opening it for a compare-and-swap save; absent storage and invalid targets do not create reviews or a database.
`editor_review` commits native comment/summary buffers on dismissal or switching, and verdicts/deletions immediately. It restores a sole stored review or opens the floating selector for multiple reviews. Exports retain their own file baseline and do not become automatic persistence targets; imports get fresh database identities. Review editors remain tab-owned native panes outside the workspace `PaneTree`. Storage opens lazily when a comparison is ready; the session supplies the configured or XDG default directory. Direct `Editor` callers inject a directory with `set_review_directory` to enable persistence.
Successful SQLite autosaves and JSON-only saves share one acknowledgement operation: clear review dirtiness and advance the owning tab's native draft saved baselines only after persistence succeeds. Failed writes retain unsaved state; JSON exports do not acknowledge a pending SQLite save.
Review replacement and comment deletion are private editor-owned operations. Replacement loads and validates its Stored/JSON/New source before retiring old bindings, installing the document, and resetting selection/navigation. Deletion retires and acknowledges only the matching comment drafts, preventing stale text from overwriting reused IDs. Both persist before opening the overview; a post-mutation save failure retains the applied, unsaved identity change. Native text/undo and general transition guards remain unchanged.
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets. - `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
- `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and syntax byte spans; it has no terminal color/geometry or editing dependency. - `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and syntax byte spans; it has no terminal color/geometry or editing dependency.
- `markdown`: owns native Markdown link/reference resolution, local destination decoding, and heading fragment lookup through the installed block/inline grammars. It has no editor lifecycle, terminal, LSP, or external-process dependency. - `markdown`: owns native Markdown link/reference resolution, local destination decoding, and heading fragment lookup through the installed block/inline grammars. It has no editor lifecycle, terminal, LSP, or external-process dependency.
@@ -33,10 +40,10 @@ Frontends drive `EditorSession::handle` or `execute`, periodically call `poll()`
`Editor::should_quit()` tells the frontend to exit its input/render loop for either quit or restart; `Editor::should_restart()` distinguishes a requested executable relaunch from an ordinary exit. Normal-mode `core.restart` / `:restart` first closes clean buffers across all tabs, panes, and historical views. Remaining dirty buffers retain unsaved text and undo/redo history, block the exit/relaunch request with a warning, and leave the editor interactive; already closed clean buffers remain closed. `core.force_restart` / `:force_restart` (alias `:restart!`) requests relaunch without saving, discarding unsaved text. Both commands take no arguments and have no default binding. Force restart uses palette confirmation, while configured action bindings execute directly. `Editor::should_quit()` tells the frontend to exit its input/render loop for either quit or restart; `Editor::should_restart()` distinguishes a requested executable relaunch from an ordinary exit. Normal-mode `core.restart` / `:restart` first closes clean buffers across all tabs, panes, and historical views. Remaining dirty buffers retain unsaved text and undo/redo history, block the exit/relaunch request with a warning, and leave the editor interactive; already closed clean buffers remain closed. `core.force_restart` / `:force_restart` (alias `:restart!`) requests relaunch without saving, discarding unsaved text. Both commands take no arguments and have no default binding. Force restart uses palette confirmation, while configured action bindings execute directly.
Session polling schedules synchronous `Editor::refresh_files()` metadata scans every 250 ms; the editor method itself is unthrottled and reports whether a redraw is needed. Scans include active and parked live buffers across inactive tabs/panes, but never immutable historical snapshots, and read content only after metadata changes. Valid changed disk text replaces only clean buffers, updates the saved baseline and text revision, clears stale undo/redo, preserves/clamps every associated view, invalidates annotations and stale requests, and refreshes live comparisons. Dirty buffers keep edits; missing, unreadable, or invalid changed files preserve text and report a warning. Session polling schedules synchronous `Editor::refresh_files()` metadata scans every 250 ms; the editor method itself is unthrottled and reports whether a redraw is needed. Scans include active and parked live buffers across inactive tabs/panes, but never immutable historical snapshots, and read content only after metadata changes. Valid changed disk text replaces only clean buffers, updates the saved baseline and text revision, clears stale undo/redo, preserves/clamps every associated view, clears diagnostics and stale requests, remaps retained semantic colors, and refreshes live comparisons. Dirty buffers keep edits; missing, unreadable, or invalid changed files preserve text and report a warning.
Every save independently re-reads disk content and compares its LF-normalized text with the saved baseline, rather than trusting polling or modification timestamps. Normal writes refuse external changes/deletion; newly opened nonexistent paths can be created normally. Typed `core.force_save` / `core.force_save_and_quit` expose canonical `:force_save [path]` / `:force_save_and_quit [path]` and aliases `:w!` / `:wq!`. Force bypasses only the external-change check, retaining snapshot read-only, target-symlink, and another-buffer path-ownership protections. Both save-and-quit variants quit only after a successful save and only when all buffers are clean. Every save independently re-reads disk content and compares its LF-normalized text with the saved baseline, rather than trusting polling or modification timestamps. Normal writes refuse external changes/deletion; newly opened nonexistent paths can be created normally. Typed `core.force_save` / `core.force_save_and_quit` expose canonical `:force_save [path]` / `:force_save_and_quit [path]` and aliases `:w!` / `:wq!`. Force bypasses only the external-change check, retaining snapshot read-only, target-symlink, and another-buffer path-ownership protections. Both save-and-quit variants quit only after a successful save and only when all buffers are clean.
Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates (empty outside Visual), `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. `visual_linewise()` is true only for the whole-row shape in `Mode::Visual`; it changes neither binding modes nor Ruby mode names. Linewise ranges expand to complete rows. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. `linewise_rows()` exposes inclusive selected row ranges only in linewise Visual, including an empty final row without changing `selections()` cardinality. The TUI highlights the copied text on those rows and one cell for each copied newline, never unused viewport space or the gutter, and labels their mode VISUAL LINE. Selection normalization, clipboard shape and contents, edit planning, and history remain inside the editor module. Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates, including selections retained during command input, `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. `visual_linewise()` and `linewise_rows()` likewise retain whole-row shape during a command prompt entered from Visual; binding modes and Ruby mode names still report Command. Linewise ranges expand to complete rows. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. `linewise_rows()` includes an empty final row without changing `selections()` cardinality. The TUI highlights copied text and one cell per copied newline, never unused viewport space or the gutter; the mode label remains COMMAND while typing. Selection normalization, clipboard shape and contents, edit planning, and history remain inside the editor module.
The TUI also observes the editor's logical caret-reveal generation. Core motion/navigation dispatch advances it even when the resulting position is unchanged, so a detached viewport can resume following without duplicating key-binding policy or changing text revisions and LSP freshness. The TUI also observes the editor's logical caret-reveal generation. Core motion/navigation dispatch advances it even when the resulting position is unchanged, so a detached viewport can resume following without duplicating key-binding policy or changing text revisions and LSP freshness.
Frontends inspect `palette()` for the optional read-only query, filtered command entries, selected index, effective bindings, and eligibility. Palette input handling and acceptance stay in the editor; registered command execution stays in the session. Frontends inspect `palette()` for the optional read-only query, filtered command entries, selected index, effective bindings, and eligibility. Palette input handling and acceptance stay in the editor; registered command execution stays in the session.
Frontends use cached `selection_navigation_hint()` for command lists and `file_navigation_hint()` for the active file view/context, without reconstructing input policy. Frontends use cached `selection_navigation_hint()` for command lists and `file_navigation_hint()` for the active file view/context, without reconstructing input policy.
@@ -51,11 +58,11 @@ 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/path changes discard overlays, and 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 hardware 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.
@@ -64,7 +71,7 @@ Frontends borrow `snippet_selections()` for the active argument's end-exclusive
The terminal guard owns raw mode, alternate-screen state, bracketed paste, mouse capture, focus reporting, DEC 2031 color-scheme notifications, and its panic hook across startup, rendering, session teardown, and relaunch. An inherited-screen restart installs cleanup and its panic hook before argument, file, or configuration parsing, resumes raw mode and input reporting without entering the alternate screen again, and clears and repaints inherited contents in its first synchronized frame. A private first-argument marker identifies this handoff without changing the original CLI arguments. Final quit, startup or I/O errors, failed relaunch, and unwinding panics restore normal input modes, the normal screen, cursor visibility, and the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while the guard is active. The terminal guard owns raw mode, alternate-screen state, bracketed paste, mouse capture, focus reporting, DEC 2031 color-scheme notifications, and its panic hook across startup, rendering, session teardown, and relaunch. An inherited-screen restart installs cleanup and its panic hook before argument, file, or configuration parsing, resumes raw mode and input reporting without entering the alternate screen again, and clears and repaints inherited contents in its first synchronized frame. A private first-argument marker identifies this handoff without changing the original CLI arguments. Final quit, startup or I/O errors, failed relaunch, and unwinding panics restore normal input modes, the normal screen, cursor visibility, and the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while the guard is active.
`tui::TerminalSession::run(inherited_screen, body)` scopes this ownership; `tui::run(&mut session, inherited_screen)` renders inside that active guard. `TerminalSession::prepare_restart()` resets process-local input state without leaving the alternate screen, 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, 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.
@@ -78,26 +85,45 @@ Comparison views reserve no separate metadata header or native-buffer help foote
Each comparison owns its split/unified mode and split axis; startup defaults apply only when opening a comparison. Source alignment and file-side availability stay independent of presentation. Unified display rows carry a side projection: equal context maps both sources, changed runs emit Before rows followed by After rows, and folds retain aligned coordinates. Native navigation/editing continues to bind source buffers rather than a synthetic unified document. The TUI reuses side-specific syntax/wrap/overlay pipelines in one unified region or the existing axis-based split geometry, exposing only the active logical side for unified hit-testing and directional focus. Each comparison owns its split/unified mode and split axis; startup defaults apply only when opening a comparison. Source alignment and file-side availability stay independent of presentation. Unified display rows carry a side projection: equal context maps both sources, changed runs emit Before rows followed by After rows, and folds retain aligned coordinates. Native navigation/editing continues to bind source buffers rather than a synthetic unified document. The TUI reuses side-specific syntax/wrap/overlay pipelines in one unified region or the existing axis-based split geometry, exposing only the active logical side for unified hit-testing and directional focus.
The resolver owns a dedicated native tab with an editable Draft and frozen read-only Alternatives projected from the current raw working buffer. Projection replaces only conflict regions; automatically merged text and pre-existing outside edits remain identical across initial alternatives. Native undo includes initial seeding. Resolution reuses DiffState alignment, excerpts, highlighting and viewport mapping, including equal-text editing. A separate read-only History uses the original jj inputs/bases; native buffer identity and frontend offsets retain draft/view state. `resolve` reads all alternating Git-backed `jj:trees` terms. Background guarded saves retain checkout/disk validation and jj snapshot/status authority; no presentation change bypasses these write guards.
Resolution exposes its projected comparison through the same active DiffState and native FilePicker as ordinary diffs. Pane labels and writability are explicit comparison properties; file selection, bindings, native navigation, layout and scrolling share the existing interaction path. Conflict state owns only projections, source choices and guarded save/check metadata; there is no separate resolver filter or file-list renderer.
Conflict History alignment lives in `diff::HistoryComparison`: it joins the selected inputs at shared base rows, combines base change ranges, retains native EOF rows, and exposes immutable column projections with source/display mapping. The frontend consumes each projection's source side rather than assigning Before/After conventions itself. `HistoryDiff` retains only presentation state: geometry, wrapping, shared scrolling, and caret visibility. Ordinary comparisons and Draft resolution continue to use their existing `DiffState` behavior.
Content painting and native caret overlays consume a borrowed `diff::ColumnView` for both ordinary comparisons and Conflict History. It resolves folds, gaps, source text and coordinates, native terminal rows, newline state, and side-specific change ranges without copying text or building a row catalog. Native caret mapping remains separate from review membership: unified equal context can carry anchors from either original source. Review freshness/filtering, pane chrome, viewport policy, wrapping, scope labels, and colors stay with their existing owners; the content painter receives only the view and already-filtered review annotations. History retains its internal pairwise `DiffState` storage.
`WheelPanels::draw_documents` owns the complete ordinary/Resolution document frame: tree preparation, viewport anchoring and scroll projection, visible-row highlighting, content/native caret painting, scope overlays, and final geometry publication. It returns only the surviving document caret for outer popup placement. Selectors, cached-error notices, and file navigation remain in outer orchestration; cached syntax errors are read before preparation to preserve notice-layout timing. The existing owner also retains immediate ordered wheel handling and previous-frame geometry; Conflict History keeps its separate presentation flow.
`Editor::scroll_distance()` supplies the session-wide optional edge margin. The TUI applies it to document/diff visual rows and unwrapped text columns, and retains independent picker offsets across navigation frames. Picker windows exclude their fixed chrome and align whole multi-row entries. File projections carry a monotonic viewport revision, so catalog/filter/expansion changes invalidate picker identity without hashing the entire catalog on every redraw. Metadata-only status changes do not move the viewport. Explicit LSP/wrap-toggle anchors and detached wheel views retain precedence. `Editor::scroll_distance()` supplies the session-wide optional edge margin. The TUI applies it to document/diff visual rows and unwrapped text columns, and retains independent picker offsets across navigation frames. Picker windows exclude their fixed chrome and align whole multi-row entries. File projections carry a monotonic viewport revision, so catalog/filter/expansion changes invalidate picker identity without hashing the entire catalog on every redraw. Metadata-only status changes do not move the viewport. Explicit LSP/wrap-toggle anchors and detached wheel views retain precedence.
Keyboard viewport actions queue per-view row and configured-step counts without moving carets. `Editor::take_viewport_scroll(height)` resolves step counts using the session-wide `scroll_step_lines` preference, or half the focused viewport when unset, and saturates multiplication before the frontend clamps movement to content bounds. Ordinary, diff, and conflict-history rendering share this resolution.
Mouse wheel input uses the last rendered document/panel rectangles for hit-testing. Document offsets and explorer scroll state remain frontend-owned; three-row wheel steps never become document cursor motions. Manual document views persist until logical caret-reveal, caret/selection, mode, revision, or buffer changes; offscreen primary and browsing-selection cursors are not placed. Hover wheel input reuses the existing editor-owned popup scrolling. Mouse wheel input uses the last rendered document/panel rectangles for hit-testing. Document offsets and explorer scroll state remain frontend-owned; three-row wheel steps never become document cursor motions. Manual document views persist until logical caret-reveal, caret/selection, mode, revision, or buffer changes; offscreen primary and browsing-selection cursors are not placed. Hover wheel input reuses the existing editor-owned popup scrolling.
Already-ready mouse reports share a redraw: at most 64 reports are applied in their original order before painting, without dropping or summing away wheel steps. Readiness checks do not wait. The first non-mouse event is retained for the next iteration after rendering, so keys, paste, and resizes cannot reuse geometry from before their layout changes. The batch bound keeps continuous mouse input from starving drawing or background polling. Already-ready mouse reports share a redraw: at most 64 reports are applied in their original order before painting, without dropping or summing away wheel steps. Readiness checks do not wait. The first non-mouse event is retained for the next iteration after rendering, so keys, paste, and resizes cannot reuse geometry from before their layout changes. The batch bound keeps continuous mouse input from starving drawing or background polling.
Already-ready Insert typing and directly bound Normal/Visual document motions also share a redraw, with the same 64-key bound and non-waiting readiness checks. The editor classifies motion bindings, including remaps and comparison text panes, so the TUI does not hard-code navigation keys. Each key still passes through session dispatch separately, preserving counts, intermediate column clamping, Visual ranges, and history. Mode changes end a batch; pending key/operator/seek prefixes, focused lists/popups, non-motion bindings, release reports, and non-key events remain redraw boundaries. No repeat timer or acceleration is added.
Ratatui's bounded layout cache is enabled: unchanged geometry and constraints reuse solved rectangles during scrolling; resizes and panel-layout changes use their own cache keys. Ratatui's bounded layout cache is enabled: unchanged geometry and constraints reuse solved rectangles during scrolling; resizes and panel-layout changes use their own cache keys.
The buffer viewport includes a fixed left gutter with right-aligned hybrid line numbers: the highlighted primary cursor line shows its one-based absolute number; every other line shows its distance from the primary. Secondary carets do not establish separate numbering origins. Width follows the total line count plus one separator cell, including an empty final row after a trailing newline; `git_gutter` bar/symbol modes add a blank cell and a marker cell between the number and separator. Number-color/none modes add no width. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Wrapped continuations and rows beyond the buffer have no number or marker. The gutter is hidden when it would leave no text column. The buffer viewport includes a fixed left gutter with right-aligned hybrid line numbers: the highlighted primary cursor line shows its one-based absolute number; every other line shows its distance from the primary. Secondary carets do not establish separate numbering origins. Width follows the total line count plus one separator cell, including an empty final row after a trailing newline; `git_gutter` bar/symbol modes add a blank cell and a marker cell between the number and separator. Number-color/none modes add no width. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Wrapped continuations and rows beyond the buffer have no number or marker. The gutter is hidden when it would leave no text column.
Repository gutter work runs off the render/editor thread. Revision/path-validated worker results update marker vectors belonging to ordinary source buffers, not historical snapshots; panes and tabs borrow their own buffer's markers. After text edits, the last completed markers for the same path remain visible until a current-revision result replaces them, rather than disappearing during each asynchronous refresh. The worker compares editor text with Git HEAD or Git-backed jj's single parent, independently of save baselines and index state. The frontend paints green additions, red deletion anchors, and yellow modifications as `▌`, symbols, or number colors; current-line cyan/bold overrides repository colors in number-color mode. Repository gutter work runs off the render/editor thread. Revision/path-validated worker results update marker vectors belonging to ordinary source buffers, not historical snapshots; panes and tabs borrow their own buffer's markers. After text edits, the last completed markers for the same path remain visible until a current-revision result replaces them, rather than disappearing during each asynchronous refresh. The worker compares editor text with Git HEAD or Git-backed jj's single parent, independently of save baselines and index state. The frontend paints green additions, red deletion anchors, and yellow modifications as `▌`, symbols, or number colors; current-line cyan/bold overrides repository colors in number-color mode.
The same cancellable worker optionally collects normal-picker status, including when line-gutter rendering is disabled. Disk metadata and dirty-buffer overlays use Git HEAD or jj's single parent without snapshotting jj or writing active refs, objects, or indexes. A private baseline index retains its stat cache across polls; conflicted jj parents are projected once per root/parent. Root, baseline, store, or builtin Git conversion-setting changes invalidate that cache. Private queries disable user filters, external diff, and textconv. Request/tab/root checks reject stale picker results; unchanged catalogs retain their ordinary scanner and filtering rules. The same cancellable worker collects normal-picker status for the startup project root, including when line-gutter rendering is disabled and no picker is open. The editor retains the last completed project-wide result across picker closure and shares it with ordinary pickers in every tab; new pickers apply it before their first render. Opening or closing a picker does not restart status work. Normal file rows reserve a fixed marker column even before the first result or when the checkout is clean. Disk metadata and dirty-buffer overlays use Git HEAD or jj's single parent without snapshotting jj or writing active refs, objects, or indexes. A private baseline index retains its stat cache across polls; conflicted jj parents are projected once per root/parent. Root, baseline, store, or builtin Git conversion-setting changes invalidate that cache. Private queries disable user filters, external diff, and textconv. Request/root checks reject stale picker results; unchanged catalogs retain their ordinary scanner and filtering rules.
## Syntax caches ## Syntax caches
Each buffer has a frontend-owned parser/tree cache. Unchanged revision/path skips comparison and parsing; unchanged text at a later revision also avoids parsing. Actual text changes produce a minimal UTF-8-safe byte edit with newline-aware Tree-sitter Points, edit the prior tree, then parse using that tree for incremental reuse. Grammar changes reset the tree, including TypeScript versus TSX despite their shared display name. Native highlight/local/injection queries resolve capture precedence and scoped definitions into nonoverlapping original-line spans; unchanged injected trees are reused. Buffer switching restores its cache, and closing retires it. Incomplete text and grammar ERROR nodes remain highlightable; genuine language/query/parser failures expose a status error and readable plain text. Full-tree highlight queries still run after incremental parsing—a deliberate ceiling to revisit if measured costly. There is no local semantic inference engine or runtime grammar download. Each buffer has a frontend-owned parser/tree cache. Unchanged revision/path skips comparison and parsing; unchanged text at a later revision also avoids parsing. Actual text changes produce a minimal UTF-8-safe byte edit with newline-aware Tree-sitter Points, edit the prior tree, then parse using that tree for incremental reuse. Grammar changes reset the tree, including TypeScript versus TSX despite their shared display name. Native highlight/local/injection queries resolve capture precedence and scoped definitions into nonoverlapping original-line spans; unchanged injected trees are reused. Buffer switching restores its cache, and closing retires it. Incomplete text and grammar ERROR nodes remain highlightable; genuine language/query/parser failures expose a status error and readable plain text. Full-tree highlight queries still run after incremental parsing—a deliberate ceiling to revisit if measured costly. There is no local semantic inference engine or runtime grammar download.
Full-source syntax work is bounded to 1 MiB, with a maximum 64 KiB source line (UTF-8 bytes, including normalized line separators in the total). Oversized buffers retain language detection and an explicit plain-text status, but allocate no source copy, per-row spans, scopes, or parse tree. Crossing either budget releases existing parser/cache allocations; unchanged oversized revisions return immediately, and shrinking below both budgets resumes normal parsing. The same policy covers ordinary buffers, diff sources including their separately stored final newline, and text-only hover caches.
Diff highlighting lazily owns two instances of the same syntax cache, keyed by the accepted comparison request ID and selected file. Before detects language from the old path when renamed; After uses the new path. Scrolling, focus changes, and explorer toggling reuse parsed spans without copying/reparsing snapshots. A fresh comparison identity prevents stale Working-checkout colors even when labels and paths are unchanged. Closing the view releases snapshot caches. Syntax styles patch foreground/modifiers over diff backgrounds; historical panes never receive live-buffer semantic tokens. Diff highlighting lazily owns two instances of the same syntax cache, keyed by the accepted comparison request ID and selected file. Before detects language from the old path when renamed; After uses the new path. Scrolling, focus changes, and explorer toggling reuse parsed spans without copying/reparsing snapshots. A fresh comparison identity prevents stale Working-checkout colors even when labels and paths are unchanged. Closing the view releases snapshot caches. Syntax styles patch foreground/modifiers over diff backgrounds; historical panes never receive live-buffer semantic tokens.
Scope context is a lazily built row index over the existing primary/injected syntax trees, invalidated by source or grammar changes and warmed by diff updates. Ordinary buffer highlighting does not build it. The frontend's visual-row projection inserts inline/sticky scope labels in wrapped and unwrapped diffs; source alignment and excerpt ranges contain no label rows. Coloring, native selection/caret overlays, and source navigation skip these presentation rows. Before/After labels use their independent trees rather than live-workspace symbol analysis.
Cached scope metadata records the declaration identifier's source point separately from its extent, including decorated definitions. The projection checks that point against each pane's actual source rows and column/wrap ranges, suppressing redundant labels and refilling reclaimed rows. Caret anchoring skips source/wrap positions only while their maximum possible label height cannot reach the target, then projects the remaining candidates. This retains the first matching anchor even when declaration visibility changes label height non-monotonically.
## Clipboard providers ## Clipboard providers
macOS publishes `public.utf8-plain-text` through the native Pasteboard Manager, checking allocation and OS-status failures before reporting success. Publication is synchronous and avoids per-copy interpreter/process startup; native calls do not have a subprocess deadline. Reads retain fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`. Both paths preserve literal RTF/EPS text rather than treating it as rich clipboard data. Linux supports Wayland with `wl-paste`/`wl-copy`, or X11 with `xclip`, on PATH with the corresponding display environment. Other platforms report unsupported access. Subprocess-backed access has a two-second deadline and bounded output; failures terminate/reap the process group. macOS publishes `public.utf8-plain-text` through the native Pasteboard Manager, checking allocation and OS-status failures before reporting success. Publication is synchronous and avoids per-copy interpreter/process startup; native calls do not have a subprocess deadline. Reads retain fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`. Both paths preserve literal RTF/EPS text rather than treating it as rich clipboard data. Linux supports Wayland with `wl-paste`/`wl-copy`, or X11 with `xclip`, on PATH with the corresponding display environment. Other platforms report unsupported access. Subprocess-backed access has a two-second deadline and bounded output; failures terminate/reap the process group.
@@ -110,7 +136,7 @@ Diff snapshots retain a separate immutable document identity, full source text,
Language configurations can own an ordered server stack. The first matching language wins extension overlap; all its matching servers receive document events, while the first handles hover/definition/completion. The Python preset uses ty then Ruff. LSP merges each worker's latest diagnostic snapshot in assignment order before emitting the unchanged core-owned update. Empty publications/retirement remove only their source; revision/path/root changes and close discard all contributions. Worker snapshots retain identity so queued pre-rename diagnostics cannot attach to a new path with the same text revision. Combined diagnostic message bytes remain bounded to 4 MiB; an overflowing publication retires its worker instead of discarding healthy sibling annotations. Workers are stored in config/root order, avoiding per-poll key cloning/sorting. Shutdown flushes the exit notification and waits for peer termination within the existing bounded grace period before process cleanup. Language configurations can own an ordered server stack. The first matching language wins extension overlap; all its matching servers receive document events, while the first handles hover/definition/completion. The Python preset uses ty then Ruff. LSP merges each worker's latest diagnostic snapshot in assignment order before emitting the unchanged core-owned update. Empty publications/retirement remove only their source; revision/path/root changes and close discard all contributions. Worker snapshots retain identity so queued pre-rename diagnostics cannot attach to a new path with the same text revision. Combined diagnostic message bytes remain bounded to 4 MiB; an overflowing publication retires its worker instead of discarding healthy sibling annotations. Workers are stored in config/root order, avoiding per-poll key cloning/sorting. Shutdown flushes the exit notification and waits for peer termination within the existing bounded grace period before process cleanup.
Semantic tokens belong only to the first assigned server. Initialization advertises standard token types/modifiers, relative encoding, full/range requests, and server refresh support; deltas, overlapping/multiline tokens, and dynamic registration are not advertised. Each buffer has at most one request in flight; changes/refreshes coalesce into the latest snapshot. Full requests are preferred, with whole-document range fallback. A linear UTF-16 decoder validates raw coordinates and legend indices before outward grapheme rounding; overlong ends truncate at EOL, unknown types and invisible spans fall back to syntax. Both wire frames and decoded spans are bounded to 4 MiB. Snapshot identity rejects stale/renamed/closed replies before decoding. Invalid semantic replies clear the overlay and report an error without retiring diagnostics/navigation; primary retirement clears its overlays, and sibling retirement leaves them intact. Semantic tokens belong only to the first assigned server. Initialization advertises standard token types/modifiers, relative encoding, full/range requests, and server refresh support; deltas, overlapping/multiline tokens, and dynamic registration are not advertised. Each buffer has at most one request in flight; changes/refreshes coalesce into the latest snapshot. Text-change notifications discard cached server coordinates without publishing an empty overlay over the editor's remapped colors. Full requests are preferred, with whole-document range fallback. A linear UTF-16 decoder validates raw coordinates and legend indices before outward grapheme rounding; overlong ends truncate at EOL, unknown types and invisible spans fall back to syntax. Both wire frames and decoded spans are bounded to 4 MiB. Snapshot identity rejects stale/renamed/closed replies before decoding. Invalid semantic replies clear the overlay and report an error without retiring diagnostics/navigation; primary retirement clears its overlays, and sibling retirement leaves them intact.
Manual completion dispatches the Insert-only `core.lsp.completion` action directly to the session, without the Ex execution path's mode/history transitions. The native worker sends the primary caret as UTF-16 and normalizes results into `CompletionItem { label, detail, primary_edit, edits, snippet }`; each `CompletionEdit` uses original-document, end-exclusive grapheme coordinates. Snippet primary text is expanded and newline-normalized before byte-relative metadata is assigned; additional edits remain plain text. Primary and additional edits form one atomic candidate. Session freshness includes the latest request, buffer/revision, primary caret, Insert source mode, and absence of modal overlays; any intervening input invalidates a pending reply. Decoding/validation errors remain completion-local and preserve healthy diagnostics/semantic tokens. Server commands, indentation adjustment, resolve requests, and automatic triggers are not supported; both frames and aggregate decoded completion text remain bounded. Manual completion dispatches the Insert-only `core.lsp.completion` action directly to the session, without the Ex execution path's mode/history transitions. The native worker sends the primary caret as UTF-16 and normalizes results into `CompletionItem { label, detail, primary_edit, edits, snippet }`; each `CompletionEdit` uses original-document, end-exclusive grapheme coordinates. Snippet primary text is expanded and newline-normalized before byte-relative metadata is assigned; additional edits remain plain text. Primary and additional edits form one atomic candidate. Session freshness includes the latest request, buffer/revision, primary caret, Insert source mode, and absence of modal overlays; any intervening input invalidates a pending reply. Decoding/validation errors remain completion-local and preserve healthy diagnostics/semantic tokens. Server commands, indentation adjustment, resolve requests, and automatic triggers are not supported; both frames and aggregate decoded completion text remain bounded.
+115 -17
View File
@@ -16,9 +16,12 @@ Vamp.configure do |config|
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.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.highlight_trailing_whitespace = false # true marks line-end whitespace in red config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\n" => "↵" } config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\n" => "↵" }
config.whitespace_scope = "all" # or "leading" for indentation only config.whitespace_scope = "all" # or "leading" for indentation only
config.tab_spaces = nil # opt in with a fixed number of spaces per Tab, e.g. 4
# config.language_tab_spaces.merge!("ruby" => 2, "makefile" => nil)
config.file_explorer_side = "right" config.file_explorer_side = "right"
config.file_explorer_view = "tree" config.file_explorer_view = "tree"
config.file_picker_view = "tree" config.file_picker_view = "tree"
@@ -27,6 +30,10 @@ 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.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
config.bind "ctrl+l", config.core.right, mode: config.modes.tree config.bind "ctrl+l", config.core.right, mode: config.modes.tree
config.plugin "plugins/greeting.rb" config.plugin "plugins/greeting.rb"
@@ -55,6 +62,8 @@ Plugin paths resolve relative to the config file, not the shell's working direct
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.
`config.scroll_step_lines` controls Normal/Visual Ctrl+u/Ctrl+d (`core.scroll_step_up` / `core.scroll_step_down`). The default `nil` scrolls half the focused text viewport, rounded down with a minimum of one screen row. A positive integer, such as `config.scroll_step_lines = 3`, fixes the distance in screen rows, including wrapped continuations and diff display rows. Counts multiply that distance; `2` then Ctrl+d scrolls six rows with this example. This session-wide setting applies to ordinary, historical, diff, and conflict text panes without moving carets or selections. It does not change the cursor-edge margin, one-row scroll actions, mouse wheel, or picker navigation. Only `nil` or integers from `1` through the platform's Rust `usize` maximum are accepted; other values reject the configuration. Distances and count multiplication saturate at Rust's `isize` limit before the viewport clamps to content bounds.
`config.highlight_trailing_whitespace` defaults to `false`. Set it to `true` to give trailing whitespace a red background in ordinary and historical buffers and both sides of split/unified diffs, including wrapped and horizontally scrolled text. Spaces, expanded tab cells, and Unicode whitespace at the end of a source line are highlighted; whitespace-only lines are included, but empty lines and padding beyond the text are not. Wrapping does not make interior whitespace trailing. Search, selections, and secondary carets retain their background precedence; diagnostic underlines remain visible. This changes display only, not file contents. Only literal Ruby booleans are accepted; other values reject the configuration. `config.highlight_trailing_whitespace` defaults to `false`. Set it to `true` to give trailing whitespace a red background in ordinary and historical buffers and both sides of split/unified diffs, including wrapped and horizontally scrolled text. Spaces, expanded tab cells, and Unicode whitespace at the end of a source line are highlighted; whitespace-only lines are included, but empty lines and padding beyond the text are not. Wrapping does not make interior whitespace trailing. Search, selections, and secondary carets retain their background precedence; diagnostic underlines remain visible. This changes display only, not file contents. Only literal Ruby booleans are accepted; other values reject the configuration.
`config.whitespace_characters` defaults to `{}` (disabled). Assign a hash mapping literal whitespace characters to dim display markers, for example `{ " " => "·", "\t" => "→", "\u00a0" => "⍽", "\n" => "↵" }` for spaces, tabs, nonbreaking spaces, and line breaks. Unlisted characters keep their ordinary appearance. Keys and values must be single-character strings; keys must be whitespace with visible terminal width, tabs, or `"\n"`, and values must be printable one-cell characters. Carriage returns and other control characters, zero-width markers, and wide markers reject the configuration. Only indicator cells receive terminal dim styling; source-text colors, diagnostic underlines, and highlighted backgrounds remain intact. `config.whitespace_characters` defaults to `{}` (disabled). Assign a hash mapping literal whitespace characters to dim display markers, for example `{ " " => "·", "\t" => "→", "\u00a0" => "⍽", "\n" => "↵" }` for spaces, tabs, nonbreaking spaces, and line breaks. Unlisted characters keep their ordinary appearance. Keys and values must be single-character strings; keys must be whitespace with visible terminal width, tabs, or `"\n"`, and values must be printable one-cell characters. Carriage returns and other control characters, zero-width markers, and wide markers reject the configuration. Only indicator cells receive terminal dim styling; source-text colors, diagnostic underlines, and highlighted backgrounds remain intact.
@@ -67,6 +76,23 @@ Whitespace inside strings recognized by the bundled syntax grammar stays literal
Markers appear in ordinary and historical buffers and both sides of split/unified diffs. Tabs and wide Unicode whitespace show the marker in their first source cell and leave remaining cells blank, preserving tab stops, wrapping, horizontal scrolling, and cursor/selection geometry. A clipped first cell hides its marker; padding beyond the source does not gain markers. Whitespace with combining characters remains intact. Syntax colors, search, selections, diagnostics, and trailing-whitespace highlighting still apply. This changes display only: editing, copying, and saving retain the original characters. Markers appear in ordinary and historical buffers and both sides of split/unified diffs. Tabs and wide Unicode whitespace show the marker in their first source cell and leave remaining cells blank, preserving tab stops, wrapping, horizontal scrolling, and cursor/selection geometry. A clipped first cell hides its marker; padding beyond the source does not gain markers. Whitespace with combining characters remains intact. Syntax colors, search, selections, diagnostics, and trailing-whitespace highlighting still apply. This changes display only: editing, copying, and saving retain the original characters.
`config.tab_spaces` defaults to `nil`, preserving literal tabs. Set an integer from `1` through `255` to insert that many spaces per Tab in Insert mode, at every caret. This is a fixed count, not alignment to the next tab stop; it does not change how existing tab characters are displayed.
`config.language_tab_spaces` defaults to `{}`. It overrides the global setting using the same case-sensitive language IDs as `config.lsp`, for example `{ "rust" => 4, "ruby" => 2, "python" => 4, "makefile" => nil }`. Syntax detection and built-in LSP presets share canonical IDs rather than maintaining separate naming conventions; other bundled IDs include `shellscript`, `cpp`, and `typescriptreact` for Bash, C++, and TSX. Display labels such as “Ruby” remain presentation-only. A `nil` override preserves literal tabs for that language, even when the global setting uses spaces. Unlisted or unknown languages and unnamed buffers without a recognized shebang use `tab_spaces`. Detection reuses syntax highlighting's filename, extension, and shebang rules, independently of whether an LSP is enabled.
Like `config.lsp`, `config.language_tab_spaces` exposes a mutable hash: use `merge!` to add or override entries, or `config.language_tab_spaces["ruby"] = 2` to change one entry. Whole-hash assignment with `=` remains supported. Both assignment and the final hash contents are validated; invalid keys or widths inserted through hash mutation reject startup configuration rather than bypassing validation.
```ruby
config.tab_spaces = 4
config.language_tab_spaces.merge!(
"rust" => 4,
"ruby" => 2,
"makefile" => nil,
)
```
Active snippets retain Tab/Shift+Tab argument navigation; after the final stop, Tab uses the configured insertion setting. Ctrl+i defaults to the same Insert action but has its own binding. Explicit bindings for either key take precedence independently. Bracketed paste, clipboard paste, completion payloads, and plugin text retain literal tab characters. Insert undo/redo and dot repeat preserve the resolved spaces or tab. Widths other than `nil` or integers in `1..255`, non-hash language overrides, and empty, non-string, or control-containing language keys reject the configuration.
`config.git_blame` defaults to `true`; set it to `false` to disable current-line inline Git blame and its background worker. Only literal Ruby booleans are accepted; other values reject the configuration. Toggle it for the running session with `:git_blame.toggle`, its palette action, or a custom binding such as `config.bind "leader b", config.core.git_blame.toggle`. There is no default shortcut. `config.git_blame` defaults to `true`; set it to `false` to disable current-line inline Git blame and its background worker. Only literal Ruby booleans are accepted; other values reject the configuration. Toggle it for the running session with `:git_blame.toggle`, its palette action, or a custom binding such as `config.bind "leader b", config.core.git_blame.toggle`. There is no default shortcut.
`config.git_gutter` defaults to `"bar"`: a fat `▌` with one blank column between the line number and marker and another before the text. `"symbols"` uses `+` / `-` / `~` with the same spacing. Added, removed, and modified lines use green, red, and yellow respectively. `"line_numbers"` colors the existing numbers without adding a marker column; the current line retains its cyan/bold caret highlight. `"none"` hides line-gutter repository indicators, not line numbers or picker file statuses. Other modes retain diagnostic number colors independently of repository markers. Unsupported strings, empty strings, and non-string values reject the configuration. `config.git_gutter` defaults to `"bar"`: a fat `▌` with one blank column between the line number and marker and another before the text. `"symbols"` uses `+` / `-` / `~` with the same spacing. Added, removed, and modified lines use green, red, and yellow respectively. `"line_numbers"` colors the existing numbers without adding a marker column; the current line retains its cyan/bold caret highlight. `"none"` hides line-gutter repository indicators, not line numbers or picker file statuses. Other modes retain diagnostic number colors independently of repository markers. Unsupported strings, empty strings, and non-string values reject the configuration.
@@ -81,6 +107,24 @@ Indicators compare the current editor text (including unsaved edits) against Git
`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`. 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.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.
```ruby ```ruby
config.bind "ctrl+b", config.core.diff.mode.toggle config.bind "ctrl+b", config.core.diff.mode.toggle
config.bind "leader v", config.core.diff.view.toggle config.bind "leader v", config.core.diff.view.toggle
@@ -91,9 +135,11 @@ 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.
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|
@@ -101,12 +147,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: "" }
@@ -114,19 +160,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 · "` when the tab owns a comparison or revision selector, otherwise `""`. 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.
@@ -134,6 +180,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:
@@ -167,12 +217,14 @@ Key notation follows [Herdr's readable style](https://herdr.dev/docs/configurati
- Modifiers: `ctrl+a` through `ctrl+z`, `alt+a` through `alt+z` (`option+` is an alias for `alt+`), `ctrl+alt+a` through `ctrl+alt+z` (either modifier order), `shift+a` through `shift+z`, and `shift+tab`. `shift+j` and `J` address the same key. Modified Backspace accepts `alt+backspace` / `option+backspace` and `cmd+backspace` / `super+backspace`. - Modifiers: `ctrl+a` through `ctrl+z`, `alt+a` through `alt+z` (`option+` is an alias for `alt+`), `ctrl+alt+a` through `ctrl+alt+z` (either modifier order), `shift+a` through `shift+z`, and `shift+tab`. `shift+j` and `J` address the same key. Modified Backspace accepts `alt+backspace` / `option+backspace` and `cmd+backspace` / `super+backspace`.
- Named punctuation: `minus`, `comma`, `ampersand`, `plus`, `backtick`. Each addresses the same binding as its literal character. - Named punctuation: `minus`, `comma`, `ampersand`, `plus`, `backtick`. Each addresses the same binding as its literal character.
`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. ASCII letters support a single modifier or combined Ctrl+Alt/Option; Shift+Tab and the modified Backspace keys above are also supported. Cmd/Super character keys, other combined modifiers, other modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key. `ctrl+i` and `tab` are distinct bindings, including inside sequences; `ctrl+m` aliases `enter`. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. The Unix TUI requests disambiguated keyboard events so supporting terminals can distinguish Ctrl+i from Tab. Legacy terminals still send both as Tab. ASCII letters support a single modifier or combined Ctrl+Alt/Option; Shift+Tab and the modified Backspace keys above are also supported. Cmd/Super character keys, other combined modifiers, other modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i`/`tab` to `config.core.jump_forward`. Insert-mode Tab inserts literal text outside snippets; inside one it moves to the next argument. Override or disable these keys with the ordinary per-mode binding interface; jump actions also appear in the command palette. Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i` to `config.core.jump_forward`, including in comparisons. Normal Tab also jumps forward outside comparisons; in comparisons it cycles panes. Insert-mode Tab and Ctrl+i insert literal text outside snippets; inside one they move to the next argument. Override or disable these keys independently with the ordinary per-mode binding interface; jump actions also appear in the command palette.
`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.
@@ -189,13 +241,17 @@ Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through
`f<char>` / `F<char>` seeks forward/backward on the current line in Normal and Visual, with counts such as `2fx`. Rebind `config.core.seek_forward` / `config.core.seek_backward` to a single key or leader sequence; the action then waits for a literal target character. For example, `config.bind "s", config.core.seek_forward` makes `2sx` seek the second following `x`. Pass `mode: config.modes.visual` to install a Visual route. These native-only actions do not have Ex commands. `f<char>` / `F<char>` seeks forward/backward on the current line in Normal and Visual, with counts such as `2fx`. Rebind `config.core.seek_forward` / `config.core.seek_backward` to a single key or leader sequence; the action then waits for a literal target character. For example, `config.bind "s", config.core.seek_forward` makes `2sx` seek the second following `x`. Pass `mode: config.modes.visual` to install a Visual route. These native-only actions do not have Ex commands.
Normal and Visual Ctrl+u/Ctrl+d invoke `config.core.scroll_step_up` / `config.core.scroll_step_down` without moving carets or selection endpoints. Counts multiply the configured `scroll_step_lines` distance (half a viewport by default). Bind `config.core.scroll_up` / `config.core.scroll_down` for one screen row per count, for example `config.bind "ctrl+e", config.core.scroll_down` to replace clone-below in Normal. Pass `mode: config.modes.visual` to rebind Visual too. These actions work in ordinary and diff text panes, use wrapped screen rows, and have no Ex commands. Cursor navigation or editing resumes caret following.
## Action reference ## Action reference
| Available modes | Methods on `config.core` | | Available modes | Methods on `config.core` |
| --- | --- | | --- | --- |
| Normal | `insert`, `append`, `open_below`, `open_above` | | Normal | `insert`, `append`, `open_below`, `open_above` |
| 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`, `command` | | Normal | `join_lines`, `undo`, `redo` |
| 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` |
@@ -203,7 +259,10 @@ Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through
| 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 | `diff.review.edit`, `diff.review.delete`, `diff.review.resolve`, `diff.review.unresolve`, `diff.review.next`, `diff.review.previous`, `diff.review.overview`, `diff.review.copy`, `diff.review.summary`, `diff.review.verdict`, `diff.review.save`, `diff.review.open`, `diff.review.new` |
| Normal, Insert, Visual, Command, Search | `diff.close` | | Normal, Insert, Visual, Command, Search | `diff.close` |
| Normal | `resolve`, `resolve.next`, `resolve.previous`, `resolve.use_source`, `resolve.source`, `resolve.base`, `resolve.next_file`, `resolve.previous_file`, `resolve.files`, `resolve.draft`, `resolve.history`, `resolve.view.toggle`, `resolve.close` |
| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` | | Normal, Insert, Visual, Command, Search | `word_wrap.toggle` |
| Normal, Insert, Visual, Command, Search | `git_blame.toggle` | | Normal, Insert, Visual, Command, Search | `git_blame.toggle` |
| Normal | `pane.split_horizontal`, `pane.split_vertical`, `pane.focus_left`, `pane.focus_down`, `pane.focus_up`, `pane.focus_right`, `pane.close`, `pane.force_close` | | Normal | `pane.split_horizontal`, `pane.split_vertical`, `pane.focus_left`, `pane.focus_down`, `pane.focus_up`, `pane.focus_right`, `pane.close`, `pane.force_close` |
@@ -211,16 +270,17 @@ Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through
| Normal | `jump_back`, `jump_forward` | | Normal | `jump_back`, `jump_forward` |
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` | | Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
| Insert | `lsp.completion`, `snippet.next`, `snippet.previous` | | Insert | `lsp.completion`, `snippet.next`, `snippet.previous` |
| Normal | `search`, `paste_before`, `paste_after` | | Normal | `search`, `search_word` |
| Normal | `paste_system_before`, `paste_system_after` | | Normal, Visual | `paste_before`, `paste_after`, `paste_system_before`, `paste_system_after` |
| Normal, Visual | `select`, `select_linewise`, `search_next`, `search_previous` | | Normal, Visual | `select`, `select_linewise`, `search_next`, `search_previous` |
| Normal, Visual | `repeat_last_edit` | | Normal, Visual | `repeat_last_edit` |
| Normal, Visual | `file_picker`, `file_explorer` | | Normal, Visual | `file_picker`, `file_explorer` |
| Normal, Visual | `document_start`, `document_end`, `go_to_line` | | Normal, Visual | `document_start`, `document_end`, `go_to_line` |
| Normal, Visual | `seek_forward`, `seek_backward` | | Normal, Visual | `seek_forward`, `seek_backward` |
| 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` |
@@ -286,6 +346,18 @@ Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work
`config.core.diff.review.toggle` is a non-mutating Normal-mode action, also available as `:diff.review.toggle` and in the palette. It marks/unmarks the highlighted changed-file leaf when the comparison's explorer is focused, or the current comparison file from a text pane. Read-only snapshots permit review marks. The default `c` shortcut applies only while browsing the diff's changed-file tree; explicit bindings and disabled keys override it. For example, `config.bind "ctrl+g", config.core.diff.review.toggle`; use the palette or a control-key binding while filtering, including List view, so printable input stays in the query. `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:
```ruby
config.bind "leader r c", config.core.diff.comment
config.bind "leader r c", config.core.diff.comment, mode: config.modes.visual
config.bind "leader r o", config.core.diff.review.overview
config.bind "leader r n", config.core.diff.review.next
```
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).
`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.
Focused selector/filter input receives printable text before Normal-mode action bindings, so `:` stays query text instead of opening Command input. Explicit tree/selection navigation overrides remain effective. Control-key editor action bindings and Ctrl+p remain available. Focused selector/filter input receives printable text before Normal-mode action bindings, so `:` stays query text instead of opening Command input. Explicit tree/selection navigation overrides remain effective. Control-key editor action bindings and Ctrl+p remain available.
@@ -296,7 +368,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
@@ -328,6 +400,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"],
@@ -342,12 +415,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:
@@ -387,7 +485,7 @@ Insert mode hides all diagnostic gutter colors, underlines, counts, and caret me
`:lsp.hover` or a configured hover action requests information at the primary caret once, regardless of count or selection count. The response opens a read-only floating popup: arrows/Ctrl+h/j/k/l scroll vertically/horizontally, Home/End select the first/last logical line, and Escape dismisses without collapsing carets. Other input dismisses and continues normal editing. Changes to the source buffer/revision/caret, intervening input, or newer requests prevent pending hover replies from reopening it. `:lsp.hover` or a configured hover action requests information at the primary caret once, regardless of count or selection count. The response opens a read-only floating popup: arrows/Ctrl+h/j/k/l scroll vertically/horizontally, Home/End select the first/last logical line, and Escape dismisses without collapsing carets. Other input dismisses and continues normal editing. Changes to the source buffer/revision/caret, intervening input, or newer requests prevent pending hover replies from reopening it.
Hover accepts plaintext/Markdown MarkupContent and string/code MarkedString forms, including arrays. The popup grows up to 100 columns and 24 rows, clamped to the available document workspace and content height. Multiline content and Markdown markers remain visible, with the same Markdown syntax colors as document buffers; named fenced code blocks use their language's bundled highlighter. Highlighting is cached across scrolling and redraws. Horizontal scrolling exposes long lines without splitting graphemes. Missing/unsupported/invalid hover results report a message without changing text, selections, or undo history. Content is bounded to 4 MiB and terminal controls are sanitized. Hover accepts plaintext/Markdown MarkupContent and string/code MarkedString forms, including arrays. The popup grows up to 100 columns and 40 rows, clamped to the available document workspace and content height. Multiline content and Markdown markers remain visible, with the same Markdown syntax colors as document buffers; named fenced code blocks use their language's bundled highlighter. Highlighting is cached across scrolling and redraws. Horizontal scrolling exposes long lines without splitting graphemes. Missing/unsupported/invalid hover results report a message without changing text, selections, or undo history. Content is bounded to 4 MiB and terminal controls are sanitized.
Completion is manual and uses only the primary insertion caret. Ctrl+n requests it; the first candidate replaces text immediately. While the list is open Ctrl+n/Ctrl+j selects next and Ctrl+k previous, including an Original text row that restores the original document and carets. Other keys close the list with no accept key. Snippets expand the whole template and highlight the first argument: typing replaces it and its mirrors, Tab/Shift+Tab moves next/previous, and the final `$0` ends navigation. Outside a snippet Tab inserts a tab; Escape returns to Normal keeping the displayed text. Secondary carets are translated, not completed independently. Cycling and argument edits belong to the pending Insert undo group; repeat records resolved text and caret placement. Rebind the Insert-only `config.core.snippet.next` / `config.core.snippet.previous` actions normally. See [editing behavior](editing.md#manual-lsp-completion). Completion is manual and uses only the primary insertion caret. Ctrl+n requests it; the first candidate replaces text immediately. While the list is open Ctrl+n/Ctrl+j selects next and Ctrl+k previous, including an Original text row that restores the original document and carets. Other keys close the list with no accept key. Snippets expand the whole template and highlight the first argument: typing replaces it and its mirrors, Tab/Shift+Tab moves next/previous, and the final `$0` ends navigation. Outside a snippet Tab inserts a tab; Escape returns to Normal keeping the displayed text. Secondary carets are translated, not completed independently. Cycling and argument edits belong to the pending Insert undo group; repeat records resolved text and caret placement. Rebind the Insert-only `config.core.snippet.next` / `config.core.snippet.previous` actions normally. See [editing behavior](editing.md#manual-lsp-completion).
+77 -6
View File
@@ -12,6 +12,7 @@ cargo test -p crossterm color_scheme_notifications
cargo test -p crossterm --doc cargo test -p crossterm --doc
cargo clippy --all-targets -- -D warnings cargo clippy --all-targets -- -D warnings
cargo fmt --package vamp --check cargo fmt --package vamp --check
rustfmt --edition 2024 --check tests/unit/battery_macos.rs tests/unit/clipboard_macos.rs
for file in src/ruby_*.rb examples/config.rb examples/plugins/greeting.rb; do for file in src/ruby_*.rb examples/config.rb examples/plugins/greeting.rb; do
ruby -c "$file" || exit ruby -c "$file" || exit
done done
@@ -20,6 +21,10 @@ rubocop
The default workspace member is the editor. `vendor/crossterm` is included to test its minimal DEC 2031 notification decoder patch; the rest of its source retains upstream formatting. The decoder checks cover fragmented reports and keyboard recovery, and its doctests verify the added event does not break upstream examples. The default workspace member is the editor. `vendor/crossterm` is included to test its minimal DEC 2031 notification decoder patch; the rest of its source retains upstream formatting. The decoder checks cover fragmented reports and keyboard recovery, and its doctests verify the added event does not break upstream examples.
`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.
@@ -34,11 +39,40 @@ Rust embeds the Ruby implementation at compile time: [`ruby_values.rb`](../src/r
## Test organization ## Test organization
Rust tests live alongside their modules; native LSP integration checks are in [`tests/lsp.rs`](../tests/lsp.rs). Rust tests live under [`tests/`](../tests/). Private unit tests are in [`tests/unit/`](../tests/unit/), linked from their owning modules with `#[cfg(test)]` and `#[path]`; nested macOS modules use `include!` to retain their private/platform scope. Root `tests/*.rs` files remain Cargo integration targets, including native LSP checks in [`tests/lsp.rs`](../tests/lsp.rs).
`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.
- Prompt-history checks cover separate session-wide command/search entries, Unicode draft restoration, editable recalls, clamped/empty/bounded history, duplicate/empty/cancelled submissions, remapped/disabled keys, and preserved Visual/read-only diff targets. Live PTY smoke exercises arrows and raw Ctrl+k/j, restores drafts, accepts a recalled search with Enter, and checks command/search isolation and unchanged source bytes.
- Command-completion checks cover forward/backward alphabetical cycling/wrap across core names, aliases and live plugin registrations, reverse-first completion and direction changes retaining the prefix, explicit execution, edits/history/cancel resets, empty/unknown/Unicode prefixes, untouched arguments, remapped/disabled keys, and preserved Visual/read-only diff targets and literal search tabs. Live PTY smoke cycles `tab.*` in both directions through wraparound, submits a completed `tab.new`, and exits with unchanged source bytes.
- Shell-command checks use file-gated subprocesses to prove editing remains live, stdout/stderr/status and cwd handling, null stdin, output bounds, launch/worker errors, and process-group cleanup on completion/drop. Spinner timing uses an injected `Instant`; render checks cover scrolling, dismissal, hidden source caret, and tiny/offset windows. Live PTY smoke gates completion, saves an edit while pending, then observes both output streams and exit status and dismisses the popup.
- Visual-command checks preserve directed characterwise/linewise ranges and multiple carets through command cancellation and plugin dispatch, exercise remapped command entry, and submit `:diff.comment` on either historical side. Live PTY smoke selects lines, cancels a prompt, then runs `:diff.comment` directly and verifies the saved original range without using the palette.
- 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.
- 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, Ctrl+i/Tab aliases, and custom bindings. 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.
- Tab checks cover shared edits/history with independent pane layouts, focused views, explorers and viewports; cross-tab close guards, immutable snapshots, hidden comparison refresh/result routing, and separate repository workers. Rendered checks cover sanitized/dirty titles, active-tab visibility in narrow terminals, overlay row reservation, and exclusion of hidden diff geometry from palette polling and viewport reporting. - Tab checks cover shared edits/history with independent pane layouts, focused views, explorers and viewports; cross-tab close guards, immutable snapshots, hidden comparison refresh/result routing, and separate repository workers. Rendered checks cover sanitized/dirty titles, active-tab visibility in narrow terminals, overlay row reservation, and exclusion of hidden diff geometry from palette polling and viewport reporting.
- Session and Ruby checks exercise real Ruby execution, command/capability ownership, effect validation, configuration fallback, plugin retirement, crash/timeout recovery, and inherited-pipe cleanup. - Session and Ruby checks exercise real Ruby execution, command/capability ownership, effect validation, configuration fallback, plugin retirement, crash/timeout recovery, and inherited-pipe cleanup.
- For chrome changes, exercise anchor/template validation, padding/edge bounds, all builtin/custom segments in either bar, named startup values, and atomic live label batches. Rendering regressions should cover swapped bars across ordinary/split/diff/selector views, full-row center anchoring, terminal-cell glyph widths, empty-content row removal, tabs-only singleton hiding in either position, active-tab preservation, padding inside styled edges, and separate prompt geometry. Labels are session-wide presentation state: test cross-tab/read-only updates and unchanged-value redraw behavior without collecting real battery data or invoking render callbacks. - For chrome changes, exercise anchor/template validation, padding/edge bounds, all builtin/custom segments in either bar, named startup values, and atomic live label batches. Rendering regressions should cover swapped bars across ordinary/split/diff/selector views, full-row center anchoring, terminal-cell glyph widths, empty-content row removal, tabs-only singleton hiding in either position, active-tab preservation, padding inside styled edges, and separate prompt geometry. Labels are session-wide presentation state: test cross-tab/read-only updates and unchanged-value redraw behavior without collecting real battery data or invoking render callbacks.
@@ -47,16 +81,48 @@ Rust tests live alongside their modules; native LSP integration checks are in [`
- 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 caret remains visible. A protocol-state regression verifies that the terminal is not left in synchronized-output mode while the next frame is computed.
- 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.
- 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.
- Hover regression checks cover content forms, sanitization, cancelled/superseded replies, invalid results, scrolling/dismissal, Unicode clipping, and preserved editing state. Rendered checks cover longer/wider documentation visibility, Markdown heading and fenced-code colors, scrolled color coordinates, and replacement content without stale highlighting. - Hover regression checks cover content forms, sanitization, cancelled/superseded replies, invalid results, scrolling/dismissal, Unicode clipping, and preserved editing state. Rendered checks cover longer/wider documentation visibility, Markdown heading and fenced-code colors, scrolled color coordinates, and replacement content without stale highlighting.
- Semantic-token checks cover full/range requests, legend/modifier remapping, UTF-16 boundaries and grapheme rounding, ASCII tab and Unicode column bounds, refresh/change coalescing, stale rename/close replies, null/empty/error fallback, primary/sibling retirement, and rendered cell precedence with syntax, diagnostics, search, selections, and carets. - Semantic-token checks cover full/range requests, legend/modifier remapping, UTF-16 boundaries and grapheme rounding, ASCII tab and Unicode column bounds, refresh/change coalescing, stale rename/close replies, null/empty/error fallback, primary/sibling retirement, and rendered cell precedence with syntax, diagnostics, search, selections, and carets. Retention checks cover typing/undo/redo in editable diffs, column/newline/Unicode remapping, overlapping-token removal, and session polling with delayed/stale server replies. Live PTY smoke holds semantic replies while typing and undoing/redoing, checking that untouched colored text is not repainted in the fallback color.
Clipboard checks use isolated providers rather than reading or overwriting the desktop clipboard. Clipboard checks use isolated providers rather than reading or overwriting the desktop clipboard. Normal `x`/Delete regressions cover Unicode graphemes, remapped actions, multiple carets with empty rows, characterwise paste, undo/redo, dot-repeat publication and failure atomicity, and unchanged Insert Delete behavior. Live PTY smoke cuts a combining grapheme, repeats over a ZWJ emoji, pastes, undoes/redoes, and checks exact saved bytes using Separate mode.
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.
Review clipboard checks cover System/Separate routing with isolated providers, exact Unicode excerpts, resolved/stale comments across files, retained unsaved drafts, unchanged storage revisions, and empty/invalid/failed-copy preservation. Live PTY smoke imports a review, copies it, pastes into an empty buffer, and verifies the saved bytes exactly match CLI `show --all`; default CLI output still excludes resolved comments.
SQLite review checks cover exact anchors across restarts and revision changes, repository isolation, independent connections and optimistic conflicts, locked/unwritable stores, incompatible schema preservation, relational constraints, full unsigned comment IDs, stable comment order, bounded/corrupt rows, rollback after partial comment insertion, private file permissions, and XDG/config path resolution. Schema 1 document storage is rejected without modification. Editor checks require explicit writes for comment/summary text, discard unsaved text on dismissal without retaining discarded undo history, prevent metadata autosaves from committing hidden drafts, persist verdicts/deletions, restore a sole review, choose among multiple reviews, and keep exports independent from autosaves. Test fixtures use isolated review directories. Live PTY smoke verifies that Escape leaves no new stored comment, `:w` persists exact text, and pane close discards a later edit; reopening and writing again preserves the saved body.
Manual-resolution checks cover immediate persistence, reopen/export/import, independent stale status, conflict-retained drafts, and incompatible version preservation. CLI checks cover filename-safe dispatch, Unicode excerpts, unresolved filtering/counts, absent storage, read-only access and permissions, symlink rejection, and incompatible schema preservation.
Live PTY/CLI smoke composes and explicitly saves a comment, resolves it in the editor, observes an empty default CLI comment list with explicit counts, retrieves the resolved comment and exact excerpt with `--all`, and unresolves it. Each transition advances the same stored review revision; listing absent storage creates no data directory.
CLI deletion checks exercise explicit review/comment IDs, resolved-comment deletion, repository isolation, preserved summaries/verdicts/other comments, last-comment deletion, missing targets/storage, and stale-save conflicts through the executable. CLI smoke verifies persisted deletion and rejects a repeated deletion without advancing the review revision.
Relational-storage smoke inspects typed review/comment columns, round-trips exact Unicode anchors through the store and JSON export/import, deletes through the real CLI, and confirms the stored review and existing export remain intact.
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.
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.
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.
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.
@@ -67,12 +133,17 @@ Diff styling checks cover independent snapshot language/shebang/injection detect
## Performance and process limits ## Performance and process limits
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance. - Retained undo/redo history stores only changed rows. Insert/replay/completion transactions still use temporary full-buffer snapshots, and undo/redo reconstructs the buffer. Ordinary single-line edits with one pane and no semantic overlay modify the line in place; multiline, multi-edit, shared-pane, and semantic-overlay batches retain the general whole-buffer path. Dirty-state comparisons are cached until text or the saved baseline changes. Movement and wrapping still scan the current line.
- Syntax falls back to plain text above 1 MiB of source or a 64 KiB line, before source copies, tree construction, or per-row syntax allocations. The detected language and fallback reason remain visible; reducing the buffer below both budgets restores highlighting. These are optional syntax limits, not file-opening or editing limits.
- Saves use buffered writes before flushing, syncing, and atomically replacing the destination. External-change checks remain independent of dirty-state caching. Large-file verification must include exact saved bytes and edit/undo/redo round-trips, not startup alone.
- Blame and gutter snapshots are size-checked before joining buffer lines; files above their existing 16 MiB limits retain editing but have no annotations. Eligibility is cached with the source revision and recovers after shrinking.
- Rapid-wheel verification must replay bursts against the running editor and measure arrival at the final viewport after input stops, not just individual frame duration. Cheap frames can still accumulate a visible backlog if each queued report requires its own redraw. - Rapid-wheel verification must replay bursts against the running editor and measure arrival at the final viewport after input stops, not just individual frame duration. Cheap frames can still accumulate a visible backlog if each queued report requires its own redraw.
- Keyboard viewport scrolling coalesces up to 64 consecutive inputs for the same resolved scroll action into one redraw. Reversals, changes between row/configured-step scrolling, and cursor motions end the batch so boundary clamping and caret reveal happen before the next action. Burst checks must include direction changes at the start/end of the buffer, not just throughput.
- PTY throughput alone cannot prove terminal presentation stays smooth: capture the editor and terminal while the user reproduces a pause. Synchronized-output commands must not reach the terminal during frame computation, even when individual frames and queued-input catch-up appear fast. - PTY throughput alone cannot prove terminal presentation stays smooth: capture the editor and terminal while the user reproduces a pause. Synchronized-output commands must not reach the terminal during frame computation, even when individual frames and queued-input catch-up appear fast.
- Annotation coordinate validation fast-paths ASCII grapheme lengths using byte lengths with CRLF pair accounting, retaining Unicode segmentation for non-ASCII text. Validation remains atomic and still checks every token; large Unicode buffers or diagnostic batches can require foreground work. - Annotation coordinate validation fast-paths ASCII grapheme lengths using byte lengths with CRLF pair accounting, retaining Unicode segmentation for non-ASCII text. Validation remains atomic and still checks every token; large Unicode buffers or diagnostic batches can require foreground work.
- Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing. - Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing.
- Repository diff currently requires Unix subprocess-group cancellation and Git-backed jj (or Git alone). Each file is limited to 16 MiB; each subprocess output stream and Working/conflict snapshot to 64 MiB; each command to 60 seconds. Limit, transport, and non-commit-tag errors are explicit rather than silently truncated. Temporary comparison stores are removed when the diff closes. - Repository diff requires Unix subprocess-group cancellation and Git-backed jj (or Git alone). Selected comparison sides support 64 MiB and 4,000,000 source lines each; frozen Working snapshots support 512 MiB total. Subprocess streams remain bounded to 64 MiB and commands to 60 seconds. Working text is frozen into private Git blobs; catalogs stay lazy and selected blobs load separately without concatenated batch copies. Limits and transport errors are explicit; temporary stores are removed on close. Picker/gutter annotation budgets remain separate.
- Large line comparisons skip equal prefixes/suffixes before allocating diff tokens; added/removed files bypass the diff algorithm. Myers work has a 100 ms deadline, after which unresolved regions can be conservatively shown as replacements without dropping text. Inline highlighting is viewport-lazy above 20,000 combined source rows; large/pathological lines use whole-line change shading. Source/display navigation uses indexed contiguous ranges rather than scanning all preceding rows.
- Normal-picker status uses the gutter worker's five-second command and 16 MiB stream limits. Catalogs are bounded to 100,000 paths; dirty-buffer batches to 1,024 files/64 MiB; individual overlays, conflict files, and rename candidates to 16 MiB; conflict/rename data to 64 MiB. Temporary baseline stores are retained for warm metadata polling and removed when the worker stops. - Normal-picker status uses the gutter worker's five-second command and 16 MiB stream limits. Catalogs are bounded to 100,000 paths; dirty-buffer batches to 1,024 files/64 MiB; individual overlays, conflict files, and rename candidates to 16 MiB; conflict/rename data to 64 MiB. Temporary baseline stores are retained for warm metadata polling and removed when the worker stops.
See [syntax caches](architecture.md#syntax-caches) for the current full-tree highlighting ceiling. See [syntax caches](architecture.md#syntax-caches) for the current full-tree highlighting ceiling.
+190 -28
View File
@@ -29,10 +29,12 @@ Indicators refresh asynchronously. Typing and undo/redo retain the last complete
| Normal | `gf` follows a Markdown file link or heading fragment at the primary caret | | Normal | `gf` follows a Markdown file link or heading fragment at the primary caret |
| Normal, Visual | `gt` goes to the first line; `gb` goes to the last line, at first nonblank; Visual extends every selection | | Normal, Visual | `gt` goes to the first line; `gb` goes to the last line, at first nonblank; Visual extends every selection |
| Normal, Visual | `<line>g` jumps immediately to that one-based source line at first nonblank (`42g`); clamps at EOF and extends Visual selections | | Normal, Visual | `<line>g` jumps immediately to that one-based source line at first nonblank (`42g`); clamps at EOF and extends Visual selections |
| Normal | Ctrl+o goes to an older jump location; Ctrl+i/Tab goes forward; counts select multiple locations | | Normal | Ctrl+o goes to an older jump location; Ctrl+i goes forward (Tab too outside comparisons); counts select multiple locations |
| Normal, Visual | Decimal prefixes repeat cursor/word/search motions: `5j`, `3k`, `2w`, `4e`, `2n`; Esc cancels a pending count | | Normal, Visual | Decimal prefixes repeat cursor/word/search motions: `5j`, `3k`, `2w`, `4e`, `2n`; Esc cancels a pending count |
| Normal, Visual | Ctrl+u/Ctrl+d scroll up/down without moving carets or selections; half a viewport by default, configurable with `config.scroll_step_lines`; counts multiply the distance |
| Normal | `J` (Shift+j) joins each targeted line with the next line | | Normal | `J` (Shift+j) joins each targeted line with the next line |
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode | | Normal | `x`/Delete cut the hovered grapheme at every caret; `u` undo; `U` (Shift+u) redo |
| Normal, Visual | `:` enters command mode; Visual selections remain available to selection-aware commands |
| Normal | `yy` copies whole lines without moving; `dd` deletes them; `cc` changes them and enters Insert; counts work (`3yy`, `2dd`, `2cc`) | | Normal | `yy` copies whole lines without moving; `dd` deletes them; `cc` changes them and enters Insert; counts work (`3yy`, `2dd`, `2cc`) |
| Normal | `D` deletes through line end; `C` changes that suffix and enters Insert; `Y` copies that suffix characterwise | | Normal | `D` deletes through line end; `C` changes that suffix and enters Insert; `Y` copies that suffix characterwise |
| Normal, Visual | `r<char>` replaces the current grapheme in Normal or every selected grapheme in Visual | | Normal, Visual | `r<char>` replaces the current grapheme in Normal or every selected grapheme in Visual |
@@ -40,19 +42,22 @@ Indicators refresh asynchronously. Typing and undo/redo retain the last complete
| Normal | `d<count>w` / `d<count>b` delete forward/backward word spans, e.g. `d2w` / `d2b` | | Normal | `d<count>w` / `d<count>b` delete forward/backward word spans, e.g. `d2w` / `d2b` |
| Normal | `c<count>w` / `c<count>b` cut the same line-local spans as `d` and enter Insert, e.g. `c2w` / `c2b` | | Normal | `c<count>w` / `c<count>b` cut the same line-local spans as `d` and enter Insert, e.g. `c2w` / `c2b` |
| Normal, Visual | `.` repeats the last successful native edit against the current targets; one undo group | | Normal, Visual | `.` repeats the last successful native edit against the current targets; one undo group |
| Normal | `/` opens search; `n` finds the next match; `N` (Shift+n) finds the previous match | | Normal | `/` opens search; `*` searches the word under the primary caret; `n`/`N` find the next/previous match |
| Normal | `v` starts characterwise Visual; `V` starts linewise Visual; `p`/`P` paste the configured clipboard after/before each caret or row for linewise fragments (system by default); Esc keeps only the primary caret | | Normal | `v` starts characterwise Visual; `V` starts linewise Visual; `p`/`P` paste the configured clipboard after/before each caret or row for linewise fragments (system by default); Esc keeps only the primary caret |
| Normal, Visual | Ctrl+k clones above; Ctrl+e clones below | | Normal, Visual | Ctrl+k clones above; Ctrl+e clones below |
| Visual | Cursor/word motions and `n`/`N` extend every selection; `y` copies; `d`/`x`/Delete cuts; `c` changes; `v`/`V` switches shape, the same selector or Esc cancels | | Visual | Cursor/word motions and `n`/`N` extend every selection; `y` copies; `d`/`x`/Delete cuts; `c` changes; `v`/`V` switches shape, the same selector or Esc cancels |
| Visual | `p` / `P` replaces each selection with the configured clipboard, without overwriting it |
| Visual | Ctrl+l splits touched rows into Normal carets at first nonblank; Ctrl+n adds the next occurrence; Ctrl+s skips the primary occurrence; Ctrl+a selects all occurrences | | Visual | Ctrl+l splits touched rows into Normal carets at first nonblank; Ctrl+n adds the next occurrence; Ctrl+s skips the primary occurrence; Ctrl+a selects all occurrences |
| 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 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 |
Outside active snippets, Insert-mode Tab inserts a literal tab by default. Set [`config.tab_spaces` and `config.language_tab_spaces`](configuration.md#preferences) to insert a fixed number of spaces, with per-language overrides. The setting applies to every caret and editable diff text; existing tabs and pasted text are not converted.
`J` removes the next line's leading spaces/tabs and preserves existing trailing whitespace. It normally adds one space, two after `.`, `?`, or `!` (Vim's default), and none before `)` or when either side is empty. The cursor lands at the join point; the join is one undo action. On the last line, `J` does nothing. `J` removes the next line's leading spaces/tabs and preserves existing trailing whitespace. It normally adds one space, two after `.`, `?`, or `!` (Vim's default), and none before `)` or when either side is empty. The cursor lands at the join point; the join is one undo action. On the last line, `J` does nothing.
`gt`/`gb` move every caret to the first nonblank grapheme on the first/last row, including the empty final row after a trailing newline. Duplicate carets and overlapping Visual selections normalize as usual. Both Visual shapes retain their anchors and extend to the destination. These motions do not edit text or add undo history; count prefixes are consumed rather than interpreted as line numbers. `g` waits for its second key, and Escape cancels only the pending prefix. In Insert and command/search prompts, `gt`/`gb` remain literal text. `gt`/`gb` move every caret to the first nonblank grapheme on the first/last row, including the empty final row after a trailing newline. Duplicate carets and overlapping Visual selections normalize as usual. Both Visual shapes retain their anchors and extend to the destination. These motions do not edit text or add undo history; count prefixes are consumed rather than interpreted as line numbers. `g` waits for its second key, and Escape cancels only the pending prefix. In Insert and command/search prompts, `gt`/`gb` remain literal text.
@@ -61,15 +66,17 @@ On macOS, these modified Backspace defaults also edit prompt and filter queries,
The terminal may deliver a native modifier or a conventional control sequence: Option may use legacy ESC+DEL, and Kitty CSI-u represents Command as **Super**, not Meta. Ghostty defaults to encoding Command+Backspace as Ctrl+u (`\x15`), which invokes the same backward line deletion without a Ghostty configuration override. Ctrl+u remains a separate, configurable binding; vamp cannot distinguish it from Command+Backspace when both send the same bytes. Vamp does not negotiate an enhanced keyboard protocol or support generic Command-modified character keys. The terminal may deliver a native modifier or a conventional control sequence: Option may use legacy ESC+DEL, and Kitty CSI-u represents Command as **Super**, not Meta. Ghostty defaults to encoding Command+Backspace as Ctrl+u (`\x15`), which invokes the same backward line deletion without a Ghostty configuration override. Ctrl+u remains a separate, configurable binding; vamp cannot distinguish it from Command+Backspace when both send the same bytes. Vamp does not negotiate an enhanced keyboard protocol or support generic Command-modified character keys.
Viewport scrolling uses screen rows, including wrapped continuations and diff display rows. It affects the focused text pane (both sides share the diff's vertical offset), and the caret may leave the screen. The next cursor motion or edit resumes caret following. Ctrl+e retains its clone-below binding; single-row scrolling is available through `config.core.scroll_up` / `config.core.scroll_down`. Insert, Command, Search, and picker Ctrl+u behavior is unchanged.
## Manual LSP completion ## Manual LSP completion
With an enabled server that provides completion, Ctrl+n in Insert requests candidates at the **primary caret only**. The first candidate is inserted immediately and a small list appears near the caret; Ctrl+n or Ctrl+j cycles forward, Ctrl+k backward, with wraparound. There is no acceptance step. The **Original text** row restores the document and carets from before the result was shown, including any additional edits. Completion never broadcasts a candidate to secondary carets: they are preserved and translated through each chosen edit. With an enabled server that provides completion, Ctrl+n in Insert requests candidates at the **primary caret only**. The first candidate is inserted immediately and a small list appears near the caret; Ctrl+n or Ctrl+j cycles forward, Ctrl+k backward, with wraparound. There is no acceptance step. The **Original text** row restores the document and carets from before the result was shown, including any additional edits. Completion never broadcasts a candidate to secondary carets: they are preserved and translated through each chosen edit.
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 literal Tab behavior; 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 hardware 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 inserts a literal tab. 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.
For example, `call(${1:argument}, ${2:count})$0` previews `call(argument, count)`. Type `value`, Tab, `3`, Tab to produce `call(value, 3)` with the caret after the closing parenthesis. Argument edits remain in the same Insert undo group, and dot repeats the resolved text and final caret rather than re-expanding placeholders. For example, `call(${1:argument}, ${2:count})$0` previews `call(argument, count)`. Type `value`, Tab, `3`, Tab to produce `call(value, 3)` with the caret after the closing parenthesis. Argument edits remain in the same Insert undo group, and dot repeats the resolved text and final caret rather than re-expanding placeholders.
@@ -125,19 +132,23 @@ Ctrl+k/Ctrl+e clone each target to the adjacent row, preserving preferred termin
Copy and cut return to Normal at each selection start; Visual cancellation keeps each active endpoint and removes highlighting. Normal Esc collapses to the primary. Characterwise `p`/`P` paste after/before each current grapheme, including multiline text. Linewise clipboard fragments paste complete rows below/above each target row, including at end of file. Internal fragments retain their shape; system clipboard shape is retained only while its bytes match the editor's last publication, and unrelated external clipboard text is characterwise. Characterwise paste lands on its last inserted grapheme, or the new row's start when its fragment ends in a newline; linewise paste lands at the first nonblank of the first inserted row. Every multi-target edit is planned against the original buffer and committed as one undo action, not one per target. Copy, movement, and cancellation do not add history or clear redo. Undo/redo restore text, the full caret set, and primary identity in Normal mode, not Visual highlighting or the clipboard. Copy and cut return to Normal at each selection start; Visual cancellation keeps each active endpoint and removes highlighting. Normal Esc collapses to the primary. Characterwise `p`/`P` paste after/before each current grapheme, including multiline text. Linewise clipboard fragments paste complete rows below/above each target row, including at end of file. Internal fragments retain their shape; system clipboard shape is retained only while its bytes match the editor's last publication, and unrelated external clipboard text is characterwise. Characterwise paste lands on its last inserted grapheme, or the new row's start when its fragment ends in a newline; linewise paste lands at the first nonblank of the first inserted row. Every multi-target edit is planned against the original buffer and committed as one undo action, not one per target. Copy, movement, and cancellation do not add history or clear redo. Undo/redo restore text, the full caret set, and primary identity in Normal mode, not Visual highlighting or the clipboard.
In Visual and Visual Line, both `p` and `P` replace each complete selected range rather than inserting beside its caret. Clipboard fragments are inserted literally, including their newlines: a characterwise fragment replacing a whole-line selection does not gain a newline, and a linewise fragment replacing characters keeps its newline. The edit returns to Normal, is one undo group across all selections, and supports dot repeat with the captured clipboard and selection intent. Neither clipboard is overwritten by the replaced text. Failed clipboard reads, invalid text, empty clipboard, or mismatched fragment counts leave the selection and document intact.
Visual `c` removes all selected text into the configured clipboard and enters Insert at each selection start. In the default System policy, clipboard publication must succeed before any deletion; failures leave text and selections intact. Deletion and replacement typing form one undo action when Insert mode is exited, including through explicit command execution. Esc without typing commits the deletion alone. An identical replacement creates no history entry and preserves redo. Changing only characterwise empty final-row selections enters Insert without overwriting either clipboard; linewise empty final rows are genuine rows and publish a newline. Visual `c` removes all selected text into the configured clipboard and enters Insert at each selection start. In the default System policy, clipboard publication must succeed before any deletion; failures leave text and selections intact. Deletion and replacement typing form one undo action when Insert mode is exited, including through explicit command execution. Esc without typing commits the deletion alone. An identical replacement creates no history entry and preserves redo. Changing only characterwise empty final-row selections enters Insert without overwriting either clipboard; linewise empty final rows are genuine rows and publish a newline.
Visual `r<char>` replaces every selected grapheme with the literal character, preserving newline separators and the clipboard. It returns to Normal at each selection start as one undo action and supports `.` repeat. Esc while waiting for the character cancels the replacement without clearing selections. Identical replacements and newline-only selections do not add history or discard redo. Visual `r<char>` replaces every selected grapheme with the literal character, preserving newline separators and the clipboard. It returns to Normal at each selection start as one undo action and supports `.` repeat. Esc while waiting for the character cancels the replacement without clearing selections. Identical replacements and newline-only selections do not add history or discard redo.
Bracketed paste broadcasts the same literal text to all Insert carets in a separate undo group, distinct from typing before/after it. A rejected paste leaves the pending change intact. Normal supports counted `yy`, `dd`, `cc`, `dw`, `db`, `cw`, and `cb`, but not arbitrary operator–motion grammar; blockwise Visual is also unsupported. `c`, `d`, and `y` type literal text in Insert mode. Bracketed paste broadcasts the same literal text to all Insert carets in a separate undo group, distinct from typing before/after it. A rejected paste leaves the pending change intact. Normal supports counted `yy`, `dd`, `cc`, `dw`, `db`, `cw`, and `cb`, but not arbitrary operator–motion grammar; blockwise Visual is also unsupported. `c`, `d`, and `y` type literal text in Insert mode.
Clipboard policy is session-wide and defaults to the system clipboard. Copy/cut store fragments in document order: one fragment broadcasts to every caret; matching fragment/caret counts pair in document order; any other count refuses paste without changing editing state. Empty clipboard paste does not edit text; copying/cutting only characterwise empty final-row selections does not overwrite either register. Linewise copy/cut retain whole-row shape, including a newline fragment for an empty final row. Open a new search or enter Ex commands after leaving Visual mode. Plugin commands execute once, not once per target: successful text effects collapse to the primary and apply once; rejected or message-only effects preserve the selection set. There is no selection-aware plugin API yet. Clipboard policy is session-wide and defaults to the system clipboard. Copy/cut store fragments in document order: one fragment broadcasts to every caret; matching fragment/caret counts pair in document order; any other count refuses paste without changing editing state. Empty clipboard paste does not edit text; copying/cutting only characterwise empty final-row selections does not overwrite either register. Linewise copy/cut retain whole-row shape, including a newline fragment for an empty final row. Open a new search after leaving Visual mode; Ex commands can be entered directly with `:` while retaining the selection. Plugin commands execute once, not once per target: successful text effects collapse to the primary and apply once; rejected or message-only effects preserve the selection set. There is no selection-aware plugin API yet.
## Clipboard policy ## Clipboard policy
`config.clipboard_mode = "system"` is the default: ordinary Visual `y`/`d`/`x`/`c` and Normal `p`/`P` use the OS clipboard. External clipboard changes are read on each paste. Set `"separate"` to keep ordinary operations on vamp's internal fragment register instead. Both registers remain independent across buffers; changing the policy does not overwrite either. Normal `x`/Delete (`config.core.delete`) copies each hovered grapheme to the configured clipboard before removing it. Combining sequences and emoji stay intact; empty lines are unchanged, and an entirely empty cut preserves the clipboard. Dot-repeat cuts and copies the current graphemes. Insert-mode Delete and its replay do not copy.
Explicit `config.core.copy_system` (Visual) and `config.core.paste_system_before` / `paste_system_after` (Normal) always use the system clipboard in either policy. They appear in the command palette and can be bound without replacing ordinary internal operations: `config.clipboard_mode = "system"` is the default: ordinary Visual `y`/`d`/`x`/`c` and Normal/Visual `p`/`P` use the OS clipboard. External clipboard changes are read on each paste. Set `"separate"` to keep ordinary operations on vamp's internal fragment register instead. Both registers remain independent across buffers; changing the policy does not overwrite either.
Explicit `config.core.copy_system` (Visual) and `config.core.paste_system_before` / `paste_system_after` (Normal/Visual) always use the system clipboard in either policy. They appear in the command palette and can be bound without replacing ordinary internal operations:
```ruby ```ruby
config.clipboard_mode = "separate" config.clipboard_mode = "separate"
@@ -149,7 +160,7 @@ For other applications, characterwise multi-selection exports join fragments wit
System paste validates UTF-8, unsupported controls, newlines, and a 4 MiB limit before editing; CRLF/CR normalize to LF. System exports also have a 4 MiB limit. Provider failures report an error without deleting/changing buffer text, caret/selection state, history, or repeat intent. No silent internal fallback occurs in System mode; choose Separate if clipboard access is unavailable. A native provider that fails after publishing may already have changed OS clipboard formats; vamp cannot roll that external side effect back. System paste validates UTF-8, unsupported controls, newlines, and a 4 MiB limit before editing; CRLF/CR normalize to LF. System exports also have a 4 MiB limit. Provider failures report an error without deleting/changing buffer text, caret/selection state, history, or repeat intent. No silent internal fallback occurs in System mode; choose Separate if clipboard access is unavailable. A native provider that fails after publishing may already have changed OS clipboard formats; vamp cannot roll that external side effect back.
macOS copies publish UTF-8 plain text directly through the native pasteboard API, avoiding interpreter/process startup for every copy or cut; paste reads still use `osascript` with JXA/AppKit. Linux Wayland uses `wl-paste`/`wl-copy`, and Linux X11 uses `xclip`. Linux tools must be on PATH with the corresponding display environment. Other platforms report unsupported access; choose Separate mode when system clipboard access is unavailable. macOS copies publish UTF-8 plain text through the native pasteboard API, and pastes read text directly through AppKit, avoiding interpreter/process startup for every copy, cut, or paste. Linux Wayland uses `wl-paste`/`wl-copy`, and Linux X11 uses `xclip`. Linux tools must be on PATH with the corresponding display environment. Other platforms report unsupported access; choose Separate mode when system clipboard access is unavailable.
## Repeat last edit ## Repeat last edit
@@ -165,14 +176,34 @@ The last recipe is session-wide across buffers, but pending Insert groups and un
## Search ## Search
Search is case-sensitive and single-line, with no regular expressions or Unicode normalization. Matches start and end on whole grapheme boundaries, including combining marks and emoji. Next/previous navigation skips the current position and wraps through the buffer; a sole match wraps back to itself. The active match is highlighted yellow. Search is case-sensitive and single-line, with no regular expressions or Unicode normalization. Matches start and end on whole grapheme boundaries, including combining marks and emoji. Next/previous navigation skips the current position and wraps through the buffer; a sole match wraps back to itself. All occurrences of the active query are highlighted yellow.
Submitting an empty search repeats the last query. Esc cancels without moving the cursor or replacing the previous query/match. A missing match reports the query, clears the highlight, and leaves the cursor unchanged; searching without a previous query also leaves the cursor unchanged. Search never edits text or adds undo history. Buffer edits and undo/redo clear the highlight but retain the query, so subsequent navigation searches the current text again. Submitting `/` keeps each caret in place if it is at or inside an occurrence of the query; otherwise, it moves that caret to the next match, wrapping as needed. This also applies to an empty submission repeating the previous query. Use `n`/`N` to explicitly move to the next/previous match.
Normal `*` highlights the primary caret's word when it selects a new query or changes the matching mode. Carets at or inside a matching occurrence stay put; carets outside one move to the next occurrence. Pressing `*` again for the same active word navigates forward; counts such as `2*` then skip to the requested occurrence, with every caret searching the primary word from its own position. Selecting a different word activates its search using the same positioning rules. It uses the same Unicode letters/digits/underscore boundaries as `w`, so `cat` skips `scatter`, `cat_cat`, and `cat2`. If the caret is not on a keyword, it takes the next keyword on that line; with none, it uses the punctuation run under or after the caret as a literal substring. A blank remainder reports no word and preserves the previous search. This also works in read-only diff text. Bind `config.core.search_word` to remap it.
`n`/`N` and an empty `/` submission retain the whole-word setting; a nonempty `/` query returns to literal substring search. Cancelling `/` preserves both the previous query and its matching behavior.
Normal-mode Escape hides search highlights without forgetting the query or matching mode. The next `*` reactivates highlighting, staying put only when at or inside a match; `n`/`N` or submitting `/` resumes navigation and restores highlighting. Escape used to cancel a prompt, selection, or pending motion keeps its existing behavior.
Submitting an empty search repeats the last query. Esc cancels without moving the cursor or replacing the previous query/match. A missing match reports the query and leaves the cursor unchanged; searching without a previous query also leaves the cursor unchanged. Search never edits text or adds undo history. Buffer edits and undo/redo retain the query and recompute its visible highlights, so subsequent navigation searches the current text again.
## Commands, saving, and pasted input ## Commands, saving, and pasted input
`:` 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.
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 `:diff.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.
Command-capable core actions derive canonical Ex names by removing `core.`: `core.quit` gives `:quit`, `core.save` gives `:save`, and `core.diff.close` gives `:diff.close`. Native editing and Insert-only completion/snippet actions remain action bindings, not additional Ex commands. Command-capable core actions derive canonical Ex names by removing `core.`: `core.quit` gives `:quit`, `core.save` gives `:save`, and `core.diff.close` gives `:diff.close`. Native editing and Insert-only completion/snippet actions remain action bindings, not additional Ex commands.
@@ -219,17 +250,17 @@ 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.
Text wraps at word boundaries where possible, with grapheme-safe breaks for long words. Whitespace is preserved; tabs keep their source-column stops and wide/combining Unicode characters retain their geometry. Only the first display segment shows a line number. Syntax and semantic colors, diagnostics, search matches, selections, and cursors follow the wrapped text. Motions and counts still use source lines, so `j`/`k`, Home/End, and saved file contents are unchanged. Vertical wheel input scrolls continuation rows; switching wrapping off restores horizontal scrolling. Text wraps at word boundaries where possible, with grapheme-safe breaks for long words. Word-break opportunities require non-whitespace text on the visual row, so indentation alone does not push a long identifier onto a new row. Whitespace is preserved; tabs keep their source-column stops and wide/combining Unicode characters retain their geometry. Only the first display segment shows a line number. Syntax and semantic colors, diagnostics, search matches, selections, and cursors follow the wrapped text. Motions and counts still use source lines, so `j`/`k`, Home/End, and saved file contents are unchanged. Vertical wheel input scrolls continuation rows; switching wrapping off restores horizontal scrolling.
## 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.
@@ -314,7 +345,7 @@ The picker includes ordinary buffers from every tab and unnamed buffers as `[No
Space o opens a floating **Tree** explorer directly in **INSERT/filter** mode, ready for typing; Space e opens or focuses the docked **Tree**, starting in **NORMAL/navigation** mode when new. Both use one catalog and instance. Invoking the other action applies that presentation's configured default while retaining query, selection identities, and surviving folder expansion. Floating Tree invocation enters filtering if not already filtering. These actions are available as `config.core.file_picker` and `config.core.file_explorer` in Normal/Visual and in the command palette. Space o opens a floating **Tree** explorer directly in **INSERT/filter** mode, ready for typing; Space e opens or focuses the docked **Tree**, starting in **NORMAL/navigation** mode when new. Both use one catalog and instance. Invoking the other action applies that presentation's configured default while retaining query, selection identities, and surviving folder expansion. Floating Tree invocation enters filtering if not already filtering. These actions are available as `config.core.file_picker` and `config.core.file_explorer` in Normal/Visual and in the command palette.
Normal pickers and explorers show the same colored **A/M/D/R** markers as the diff file picker: green additions, yellow modifications, red deletions, and cyan renames (`old -> new`). Unchanged files remain visible. Status compares disk and unsaved buffer text with Git `HEAD` or Git-backed jj's single parent `@-`; it works in both Tree and List, even with `git_gutter = "none"`. Missing tracked files remain as deleted entries; opening one uses the usual missing-file behavior. Status refreshes on opening, edits/saves, focus regain, and once per second. Ctrl+r rescans the normal catalog and refreshes status; status polling alone does not rescan for newly created files. Normal pickers and explorers show the same colored **A/M/D/R** markers as the diff file picker: green additions, yellow modifications, red deletions, and cyan renames (`old -> new`). Unchanged files remain visible. Status compares disk and unsaved buffer text with Git `HEAD` or Git-backed jj's single parent `@-`; it works in both Tree and List, even with `git_gutter = "none"`. Missing tracked files remain as deleted entries; opening one uses the usual missing-file behavior. Status loads asynchronously from startup and refreshes on edits/saves, focus regain, and once per second, even while the picker is closed. Opening or reopening a picker uses the latest completed project-wide result without restarting the status query. The marker column always reserves its width, so filenames do not shift while the initial result arrives or when the checkout becomes clean. Ctrl+r rescans the normal catalog and refreshes status; status polling alone does not rescan for newly created files.
While a docked Tree has focus in **NORMAL/navigation** mode, leader sequences work in ordinary and diff views: Space o replaces the dock with the floating picker, and Space e returns focus to the document like Escape while leaving the dock visible. Invoking Space e again from the document refocuses it. Tree/selection navigation overrides retain priority. In **INSERT/filter** mode and List query fields, printable leader keys remain literal query text rather than triggering editor actions; configured control-key action bindings remain available. While a docked Tree has focus in **NORMAL/navigation** mode, leader sequences work in ordinary and diff views: Space o replaces the dock with the floating picker, and Space e returns focus to the document like Escape while leaving the dock visible. Invoking Space e again from the document refocuses it. Tree/selection navigation overrides retain priority. In **INSERT/filter** mode and List query fields, printable leader keys remain literal query text rather than triggering editor actions; configured control-key action bindings remain available.
@@ -328,6 +359,8 @@ Both presentations default to Tree; [configuration preferences](configuration.md
Floating Tree opens in explorer **INSERT** mode; a new docked Tree opens in **NORMAL**. Press `i` in NORMAL to resume editing the existing filter; the `i` itself is not added to the query. Typing and Backspace filter full relative paths by case-insensitive whitespace tokens. These are explorer-local modes, not changes to the file buffer's mode. Matching nodes appear with ancestor context, and query changes initially select a direct match. Escape in INSERT changes only to NORMAL: the filter, visible results, highlighted item, and explorer focus remain. NORMAL displays the active filter and navigates that filtered tree. Escape from NORMAL returns focus to the buffer (or closes floating search). List always accepts query typing immediately. Floating Tree opens in explorer **INSERT** mode; a new docked Tree opens in **NORMAL**. Press `i` in NORMAL to resume editing the existing filter; the `i` itself is not added to the query. Typing and Backspace filter full relative paths by case-insensitive whitespace tokens. These are explorer-local modes, not changes to the file buffer's mode. Matching nodes appear with ancestor context, and query changes initially select a direct match. Escape in INSERT changes only to NORMAL: the filter, visible results, highlighted item, and explorer focus remain. NORMAL displays the active filter and navigates that filtered tree. Escape from NORMAL returns focus to the buffer (or closes floating search). List always accepts query typing immediately.
Search reuses the diff revision selector's fuzzy matcher in both Tree and List, including changed-file explorers. Each whitespace-separated token must match an ordered character subsequence of the full relative path; tokens can appear in any order. For example, `sr/fp RS` finds `src/fuzzy_parser.rs`. Catalog order and folder context stay unchanged rather than ranking the results.
| File-view input | Behavior | | File-view input | Behavior |
| --- | --- | | --- | --- |
| Type / Backspace | Type query text in List/explorer INSERT / remove one whole grapheme; explorer NORMAL uses hjkl for navigation | | Type / Backspace | Type query text in List/explorer INSERT / remove one whole grapheme; explorer NORMAL uses hjkl for navigation |
@@ -371,13 +404,51 @@ 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 / 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.
Standard GitHub/GitLab/Gitea hosts and Codeberg are detected from HTTPS or SSH remote URLs. For another host, set `remote.<remote>.vamp-forge` to `github`, `gitlab`, or `gitea` in the repository's Git configuration, for example `git config remote.origin.vamp-forge gitea`. For a non-colocated jj repository, use `git --git-dir .jj/repo/store/git config remote.origin.vamp-forge gitea`.
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 (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 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.
Missing or unprepared historical metadata is reported when selecting the affected request; it does not block listing other requests or substitute a live branch tip.
Comparison discovery builds the changed-path/status catalog first. File text, notices, and hunks load asynchronously when a file is selected and remain cached for that comparison. Failed file loads can be retried by selecting the file again; cancelled or superseded results cannot replace the current selection. Working-checkout comparisons still capture working bytes and the historical content needed to identify changes, preserving the frozen Before snapshot and unsaved After buffers. Comparison discovery builds the changed-path/status catalog first. File text, notices, and hunks load asynchronously when a file is selected and remain cached for that comparison. Failed file loads can be retried by selecting the file again; cancelled or superseded results cannot replace the current selection. Working-checkout comparisons still capture working bytes and the historical content needed to identify changes, preserving the frozen Before snapshot and unsaved After buffers.
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. **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.
**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.
Deleted or conflicted bookmarks unrelated to the selected endpoints do not block opening or refreshing a comparison.
**Pinned After editability tracks the checkout without moving either endpoint**, checked asynchronously once per second, including inactive tabs and when `config.diff_follow_working_copy = false`. Git matches the selected commit to HEAD; jj matches its change ID so snapshot/description rewrites of the same change remain editable. A matching After uses the shared live buffer. Switching away restores the original read-only After snapshot; switching back reuses the live buffer's unsaved edits and undo history. A failed checkout check revokes pinned After editing until a successful check restores eligibility. **Pinned After editability tracks the checkout without moving either endpoint**, checked asynchronously once per second, including inactive tabs and when `config.diff_follow_working_copy = false`. Git matches the selected commit to HEAD; jj matches its change ID so snapshot/description rewrites of the same change remain editable. A matching After uses the shared live buffer. Switching away restores the original read-only After snapshot; switching back reuses the live buffer's unsaved edits and undo history. A failed checkout check revokes pinned After editing until a successful check restores eligibility.
Refresh retains the selected path when still present, explorer query/view/visibility, logical text-side focus, and split/unified/orientation/wrap choices; moving the Working endpoint clears review marks, while pinned checkout-only transitions retain them. Removed selections fall back to the first changed file. 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. **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.
@@ -387,6 +458,10 @@ 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 · 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.
Text panes use normal buffer interaction regardless of writability: motions and counts, search, Visual/Visual Line selections, multiple carets, yank, remapped bindings, and non-mutating actions work in Before and After. Read-only snapshots reject insert/delete/change/paste, repeat edits, undo/redo, mutating plugin effects, and saves, including saves to another path. Their text retains the revision's final newline. Snapshots use separate virtual LSP document identities and do not appear in ordinary buffer lists or dirty-buffer comparisons. Closing the diff disposes unreferenced snapshots and restores the live or original buffer in its workspace pane; snapshots displayed by other panes or tabs remain read-only until closed. Explicit buffer commands and Markdown/plugin navigation retain their ordinary behavior; native LSP definitions use the routing below. Text panes use normal buffer interaction regardless of writability: motions and counts, search, Visual/Visual Line selections, multiple carets, yank, remapped bindings, and non-mutating actions work in Before and After. Read-only snapshots reject insert/delete/change/paste, repeat edits, undo/redo, mutating plugin effects, and saves, including saves to another path. Their text retains the revision's final newline. Snapshots use separate virtual LSP document identities and do not appear in ordinary buffer lists or dirty-buffer comparisons. Closing the diff disposes unreferenced snapshots and restores the live or original buffer in its workspace pane; snapshots displayed by other panes or tabs remain read-only until closed. Explicit buffer commands and Markdown/plugin navigation retain their ordinary behavior; native LSP definitions use the routing below.
@@ -397,16 +472,64 @@ Accepted LSP definitions center the selection's caret row once in the destinatio
A definition whose source range **overlaps an actual added, removed, or replaced line** stays in the same comparison and selects the correct file and side, loading file details on demand. For a location link, the symbol's full target range determines overlap and its selection range determines the caret: an unchanged declaration with a changed body still belongs in the comparison. Source coordinates belong to that side, including old paths for renames; navigation reveals the exact selection row, even when previously omitted, never the nearest excerpt. A wholly unchanged target opens a **new ordinary tab**, even when it is visible context or omitted text in a changed file. Files outside the comparison's changes also open in a new tab. The original comparison, pane layout, source caret, excerpts, and dirty buffers remain intact. Loaded targets share their existing text and undo history rather than reloading disk; references to snapshot URIs retain the historical text and read-only identity in the new tab, even after the comparison closes. Unreadable files, invalid UTF-8, invalid source ranges, and unsupported remote URIs do not create a tab or disturb the source. A definition whose source range **overlaps an actual added, removed, or replaced line** stays in the same comparison and selects the correct file and side, loading file details on demand. For a location link, the symbol's full target range determines overlap and its selection range determines the caret: an unchanged declaration with a changed body still belongs in the comparison. Source coordinates belong to that side, including old paths for renames; navigation reveals the exact selection row, even when previously omitted, never the nearest excerpt. A wholly unchanged target opens a **new ordinary tab**, even when it is visible context or omitted text in a changed file. Files outside the comparison's changes also open in a new tab. The original comparison, pane layout, source caret, excerpts, and dirty buffers remain intact. Loaded targets share their existing text and undo history rather than reloading disk; references to snapshot URIs retain the historical text and read-only identity in the new tab, even after the comparison closes. Unreadable files, invalid UTF-8, invalid source ranges, and unsupported remote URIs do not create a tab or disturb the source.
Use the ordinary **file-picker/file-explorer actions and bindings**: Space `o` opens a floating picker, Space `e` opens/focuses the docked explorer, and Ctrl+w closes the focused picker/explorer and reclaims its width. Remapped bindings work too; Ctrl+e is no longer reserved by the diff. The changed-path catalog retains A/M/D/R markers and the normal compact tree, filtering, folder expansion, and configurable `tree`/`selection` navigation. It includes removed and historical paths without scanning the working directory. Enter toggles a folder or opens the selected comparison file and focuses its first visible text pane: After for added files, otherwise Before. While the explorer is hidden, Tab/Shift+Tab cycles between visible text panes only; a single-pane view keeps its focus. Use the ordinary **file-picker/file-explorer actions and bindings**: Space `o` opens a floating picker, Space `e` opens/focuses the docked explorer, and Ctrl+w closes the focused picker/explorer and reclaims its width. Remapped bindings work too; Ctrl+e is no longer reserved by the diff. The changed-path catalog retains A/M/D/R markers and the normal compact tree, filtering, folder expansion, and configurable `tree`/`selection` navigation. It includes removed and historical paths without scanning the working directory. Enter toggles a folder or opens the selected comparison file and focuses After when that pane exists; deleted files and other Before-only views focus Before. While the explorer is hidden, Tab/Shift+Tab cycles between visible text panes only; a single-pane view keeps its focus.
File rows show colored **A** (added, green), **M** (modified, yellow), **D** (removed, red), or **R** (renamed, cyan) markers in Tree and List views, including filtered results. Rename rows show `R old/path -> new/path`; directories have no file-status marker. Ordinary file exploration remains unannotated. File rows show colored **A** (added, green), **M** (modified, yellow), **D** (removed, red), or **R** (renamed, cyan) markers in Tree and List views, including filtered results. Rename rows show `R old/path -> new/path`; directories have no file-status marker.
Mark or unmark a diff file with **`c` while browsing the changed-file tree**, **`:diff.review.toggle`**, or the command-palette action. With the changed-file explorer focused, it toggles the highlighted file without opening it; from a text pane, the command toggles the displayed file. Reviewed files show a green **`✓` immediately before A/M/D/R** in Tree and List views. Directories and ordinary filesystem pickers cannot be marked. While filtering, including List view, `c` remains query text; use the palette or a control-key binding there. Explicit bindings take precedence over the default browsing shortcut. Mark or unmark a diff file with **`c` while browsing the changed-file tree**, **`:diff.review.toggle`**, or the command-palette action. With the changed-file explorer focused, it toggles the highlighted file without opening it; from a text pane, the command toggles the displayed file. Reviewed files show a green **`✓` immediately left of A/M/D/R, separated by one blank column**, in Tree and List views. The modification indicator keeps its existing indentation; the review marker uses the preceding gutter space instead of adding padding. Diff rows use the selection background and cursor rather than a `>` prefix. Directory review marks cannot be toggled directly, and ordinary filesystem pickers have no review marks. While filtering, including List view, `c` remains query text; use the palette or a control-key binding there. Explicit bindings take precedence over the default browsing shortcut.
Collapsed folders show a green **`✓`** when every changed file beneath them is reviewed, including nested files and descendants hidden by filtering. Expanded folders stay unmarked. Folder marks update automatically when a descendant's review mark or the changed-file catalog changes.
Review marks are saved with the selected local review and restored by immutable per-file Before/After identities and file paths. Changes to unrelated files or commit metadata do not clear them; changes to either side's bytes, final newline, file mode, or rename paths require re-review. They survive filtering, file navigation, lazy loading, tabs, split/unified/orientation changes, and pinned checkout-only transitions. Editing a writable After file clears only that file's mark and saves the removal; unchanged refreshes and saves preserve it. Marks on live text also retain its exact contents, including the final newline, so different unsaved text cannot inherit a checkmark. Marks do not change document text, undo history, or repository state. Without configured SQLite storage, use `:diff.review.save path` to save them as JSON. Older revision-pair-scoped marks remain stored but must be marked again to acquire per-file identities; comments, summaries, and verdicts are unaffected.
**Local review comments** share local storage with the file-reviewed checkmarks above; neither feature publishes to GitHub. Focus either diff text pane and run **`:diff.comment`** to comment on the current line. A characterwise Visual selection (`v`) anchors its exact text, including partial lines and multiline spans; Visual Line (`V`) anchors whole selected lines. Type **`:diff.comment`** and press Enter, or use the palette/a configured binding. Multiple independent carets/ranges must be reviewed separately.
Comments, summaries, and the review overview open in a focused floating editor over the whole diff text workspace, not a source-side split. The source layout, file explorer, and viewports stay unchanged. Normal editing, selections, and undo/redo work in editable review buffers. Escape in Normal mode or `:pane.close` dismisses the window and returns to the diff, discarding that editor's unsaved text. An unsaved new comment is not attached to the review; an existing comment or summary reopens with its last saved text and no discarded undo history. Insert/Visual/Command Escape first retains its usual modal behavior. Source rows are never replaced with comment text. Magenta **`○`** gutter markers identify saved comment ranges; **`●`** identifies the selected comment. Its exact current text range has a magenta background, including wrapped continuations and selected newline cells; native Visual selections take precedence. Stale comments have neither markers nor text highlighting. Markers preserve addition/deletion signs, appear only on the first wrapped segment, and also work in unified view.
Local review buffers (comments, summaries, overview, and stored-review selector) soft-wrap by default without inserting newlines into saved text. Use `:word_wrap.toggle` to disable wrapping for the focused review buffer; reopening it retains that choice for the session. Source diff wrapping remains independent.
| Command | Effect |
| --- | --- |
| `:diff.comment` | Add a comment on the current line or exact selected text range, on the focused side |
| `:diff.review.edit [id]` | Edit an existing comment; defaults to the overview entry under the caret or selected comment |
| `:diff.review.delete [id]` | Delete that draft comment |
| `:diff.review.resolve [id]` / `:diff.review.unresolve [id]` | Mark a comment resolved/unresolved; uses the same default selection as edit/delete |
| `:diff.review.next` / `:diff.review.previous` | Cycle comments, opening their text and revealing a matching source anchor |
| `:diff.review.overview` | Refresh/open a read-only summary and comment list; Enter edits the entry under the caret |
| `:diff.review.copy` | Copy all saved comments in the selected review to the configured clipboard, using the CLI text format |
| `:diff.review.summary` | Compose the review summary |
| `:diff.review.verdict looks_good` | Record **Looks good** |
| `:diff.review.verdict changes_requested` | Record **Changes requested**; `none` clears the verdict |
| `:diff.review.save [path]` | Commit to SQLite; with a path, also export JSON for sharing |
| `:diff.review.open [id or path]` | Show stored reviews, open a stored review ID, or import a JSON review as a new review |
| `:diff.review.new` | Create a separate stored review for the current repository |
Comment and summary text is committed only by **`:w`** or **`:diff.review.save`**, which saves the entire review, including hidden drafts. Dismissing an editor discards its unsaved text; switching between review editors can retain hidden drafts but does not save them. File checkmarks, verdicts, deletions, and manual resolution changes commit immediately to local SQLite without committing draft text. `:diff.review.save path` or `:w path` additionally exports JSON; later metadata autosaves still go to SQLite and leave that export unchanged. Imports create a new stored review identity rather than overwriting another review.
`:diff.review.copy` copies the same text as `vamp --review show ID --all`: repository, summary, verdict, counts, and every saved comment with its anchor and exact excerpt. Resolved and stale comments are included, across all files in the selected review. `clipboard_mode` chooses the system clipboard or Separate-mode internal register. Copying does not save or include unsaved comment/summary drafts. No comments or a clipboard write failure reports an error without replacing the register.
From the shell, use `vamp --review delete REVIEW_ID COMMENT_ID [--repo PATH] [--json]` to delete a stored comment immediately without opening the editor. Find IDs with `vamp --review list` and `vamp --review show REVIEW_ID --all`. The repository defaults to the current checkout. Both resolved and unresolved comments can be deleted; the review and any JSON exports remain intact. An editor holding the old review revision must reload before saving.
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.
Write errors, locked databases, incompatible schemas, and conflicting edits from another editor instance retain dirty drafts and report the failure. Failed text saves leave the committed review unchanged; unrelated metadata autosaves cannot commit those drafts. Normal destructive closes remain guarded until drafts are explicitly saved or discarded. `:w!` does not bypass database revision conflicts; export your draft before reopening the current stored review. JSON exports retain external-file-change protection. Only the current database schema is accepted; existing incompatible databases are preserved rather than reset or migrated.
The current SQLite format is schema 3: typed `reviews`, `comments`, and `reviewed_files` tables, not serialized JSON documents. Older databases are incompatible and are left untouched; there is no migration or automatic reset. JSON version 4 is the sharing format, independent of the database schema.
Version 4 JSON is the agent-readable sharing format: repository root, summary, optional verdict, comments, and a required `reviewed_files` array. Each reviewed file stores its immutable per-file `revisions` pair (`file:` identities encoding mode, Git object ID or exact data, and notices), relative `path`, optional rename `old_path`, and optional exact live `after` text. Comments have stable IDs, bodies, required boolean `resolved` states, and anchors. New comments are unresolved. Database review IDs and optimistic storage revisions stay internal. Each anchor retains its immutable comparison revision, side, relative path (old path for renamed Before files), and exact whole-file text including the final newline. `start` and `end` contain zero-based source `row` and extended-grapheme `column` coordinates; `end` is exclusive. Columns count complete Unicode graphemes, not bytes, code points, UTF-16 units, or screen cells. Overview/window labels display one-based rows and columns. Older JSON formats are rejected and preserved without migration. Working comparisons use content-addressed snapshot IDs; captured text additionally freezes live unsaved edits. Stored reviews have a 64 MiB aggregate text budget including 64 bytes of overhead per comment and reviewed file; imported/exported JSON files have a separate 64 MiB serialized-size limit.
Reopening requires the same repository root. Unchanged comment ranges follow edits elsewhere in the file, including inserted/deleted lines above and column shifts before a partial-line selection. Working-snapshot refreshes can retain these attachments; unrelated immutable revisions and paths do not match. Mapping requires an unchanged, unambiguous text region containing the selection. Modified/deleted selections or ambiguous repeated regions are **stale**: comments remain available through the overview and next/previous navigation, with original snapshot coordinates and no current-source marker or jump. Saved anchors and exported excerpts remain unchanged. Unloaded files are shown as **not checked** until visited; refresh the overview to update its statuses. There is no server, authentication, or review-publishing operation.
Resolution is a manual human decision, independent of anchor freshness: a comment can be both resolved and stale. Overview entries and comment titles label resolved/unresolved status. Resolved comments remain in the overview and next/previous navigation, and can still be edited or reopened. Resolving never changes an anchor or deletes feedback.
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.
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.
**Scope labels** identify named enclosing functions/classes and containers, such as `impl Review › run`. Muted, unnumbered rows supply context when the declaration is vertically outside that pane's viewport; scrolling it back into view removes the label and reclaims its row. If an unwrapped declaration remains vertically visible but its symbol is clipped horizontally, its label instead overlays the preceding content row. On the first visible row, that label is hidden so the pane border stays intact. Horizontal scrolling never inserts a row or shifts source text; scrolling the symbol back into view restores the covered row. Decorators alone do not count as the declaration name. Visibility follows source columns and wrapped segments independently for each side. Each split side uses its own source, so renamed functions keep distinct old/new names. Unified labels use `−` for Before, `+` for After, and `±` for shared names. Narrow panes prefer the innermost name. Labels are presentation only: motions, selections, copying, editing, and line jumps retain original source coordinates. Context comes from the existing Tree-sitter trees, including supported injected code, without requiring an LSP; unsupported syntax gets no guessed label.
When the reserved sticky row can show the immediately preceding declaration with its name visible, it renders that declaration as ordinary numbered source instead. Scrolling farther into the body replaces it with the unnumbered label; scrolling back restores the declaration.
Overlays never cover a visible declaration name on the preceding row, including a different Before/After declaration in unified view; the label is hidden instead.
Within an excerpt, additional hunks and alignment gaps do not repeat an unchanged scope label. A new excerpt or an actual scope change gets a fresh label when needed; sticky context is retained independently for each side.
In **Normal mode** with a text pane focused, **`[` expands above** and **`]` expands below** the excerpt containing the caret, revealing up to **15 lines** per keypress in either pane. In editable After's Insert mode these keys insert literal text. Expansion clamps at file boundaries and merges excerpts when they meet. Existing visible source stays anchored. Opening a file resets its excerpts to the configured initial context. Live edits recompute changes while retaining expanded context; the caret stays visible even when the last text change is reverted. In **Normal mode** with a text pane focused, **`[` expands above** and **`]` expands below** the excerpt containing the caret, revealing up to **15 lines** per keypress in either pane. In editable After's Insert mode these keys insert literal text. Expansion clamps at file boundaries and merges excerpts when they meet. Existing visible source stays anchored. Opening a file resets its excerpts to the configured initial context. Live edits recompute changes while retaining expanded context; the caret stays visible even when the last text change is reverted.
Live typing highlights only the displayed source rows while retaining each snapshot's original syntax tree for multiline and injected-language context. Line-width caches and unchanged hunk layouts avoid repeated full-file work; edits that change line alignment or revert changes recompute the diff. Already-queued printable Insert keys share a redraw, but every key still passes through normal bindings, snippet handling, and undo history. Mode changes and other terminal events retain their order. Live typing highlights only the displayed source rows while retaining each snapshot's original syntax tree for multiline and injected-language context. Line-width caches and unchanged hunk layouts avoid repeated full-file work; edits that change line alignment or revert changes recompute the diff. Already-queued printable Insert keys share a redraw, but every key still passes through normal bindings, snippet handling, and undo history. Mode changes and other terminal events retain their order.
@@ -419,7 +542,9 @@ Diff views inherit the originating buffer's word-wrap setting. `word_wrap.toggle
In split diffs, changed text rows use muted red backgrounds in Before and muted green backgrounds in After, including blank tails and real empty lines. Entirely added or deleted files retain the normal background; colored line numbers and `+`/`-` gutter markers indicate their changes. Foreground colors use the editor's bundled Tree-sitter syntax highlighting, parsed independently for each snapshot and its path (including the old path for renames). Native text panes also display their buffer's selections, search matches, semantic highlighting, and diagnostics. Unknown languages retain the normal foreground; historical snapshots do not borrow live-buffer LSP tokens. In split diffs, changed text rows use muted red backgrounds in Before and muted green backgrounds in After, including blank tails and real empty lines. Entirely added or deleted files retain the normal background; colored line numbers and `+`/`-` gutter markers indicate their changes. Foreground colors use the editor's bundled Tree-sitter syntax highlighting, parsed independently for each snapshot and its path (including the old path for renames). Native text panes also display their buffer's selections, search matches, semantic highlighting, and diagnostics. Unknown languages retain the normal foreground; historical snapshots do not borrow live-buffer LSP tokens.
Character-wise highlighting is enabled automatically within paired changed lines: only changed graphemes receive a stronger red/green background, while common text retains the muted row tint. Combining sequences, wide characters, and expanded tabs keep their source geometry through horizontal scrolling and word wrap. Syntax/semantic foregrounds remain intact, and cursor/search/selection styles keep their normal precedence. Character ranges are cached with aligned rows; stable live edits refresh only the edited paired lines rather than computing character diffs during painting. The row tint also covers the line-number gutter on wrapped continuations and blank wrap-padding rows on the shorter side of an aligned pair. Empty source lines keep the same full-row tint; missing source counterparts retain their normal-background hatching.
Character-wise highlighting is enabled automatically within paired changed lines, with a stronger red/green background over changed grapheme spans. To reduce fragmented highlighting, changes separated by at most two unchanged graphemes are merged; short unchanged islands between large edits are also absorbed using VS Code-style size heuristics. Larger common regions retain the muted row tint. Combining sequences, wide characters, and expanded tabs keep their source geometry through horizontal scrolling and word wrap. Syntax/semantic foregrounds remain intact, and cursor/search/selection styles keep their normal precedence. Character ranges are cached with aligned rows; stable live edits refresh only the edited paired lines rather than computing character diffs during painting.
Diff backgrounds blend the terminal's default background with its red/green palette colors: an 18% accent for changed rows and a stronger 38% accent for changed characters, both computed from the original background. Read-only OSC 11/4 queries run after the initial paint, on focus regain, and whenever a subscribed DEC 2031 color-scheme notification arrives. While a diff is visible in any pane, a nonblocking one-second palette probe also catches external theme changes without a notification, including changes between two themes with the same light/dark preference. Ordinary editor views do not poll; cached colors apply across editor panes. If the complete palette is unavailable within 250 ms, change backgrounds remain normal and gutter colors/markers still identify changes. Diff backgrounds blend the terminal's default background with its red/green palette colors: an 18% accent for changed rows and a stronger 38% accent for changed characters, both computed from the original background. Read-only OSC 11/4 queries run after the initial paint, on focus regain, and whenever a subscribed DEC 2031 color-scheme notification arrives. While a diff is visible in any pane, a nonblocking one-second palette probe also catches external theme changes without a notification, including changes between two themes with the same light/dark preference. Ordinary editor views do not poll; cached colors apply across editor panes. If the complete palette is unavailable within 250 ms, change backgrounds remain normal and gutter colors/markers still identify changes.
@@ -427,10 +552,41 @@ Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings li
In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Switching Before/After synchronizes the primary cursor by aligned diff row and display column. A missing counterpart uses the nearest visible source row; shorter lines clamp the column while retaining the preferred display column for the return trip. Vertical wheel input scrolls the visible panes without moving the focused text caret; switching sides by vertical wheel also synchronizes the cursor. Over Files it navigates visible tree rows without opening them. Horizontal wheel input in either split synchronizes the Before/After horizontal offsets, bounded by the longer side, without changing focus, cursor, or selections; over Files it is consumed. Added/deleted files retain single-pane scrolling. While a comparison is open, Escape cancels modes, prompts, or filtering, or returns picker focus to the last bound text pane; it leaves the comparison open. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias. In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Switching Before/After synchronizes the primary cursor by aligned diff row and display column. A missing counterpart uses the nearest visible source row; shorter lines clamp the column while retaining the preferred display column for the return trip. Vertical wheel input scrolls the visible panes without moving the focused text caret; switching sides by vertical wheel also synchronizes the cursor. Over Files it navigates visible tree rows without opening them. Horizontal wheel input in either split synchronizes the Before/After horizontal offsets, bounded by the longer side, without changing focus, cursor, or selections; over Files it is consumed. Added/deleted files retain single-pane scrolling. While a comparison is open, Escape cancels modes, prompts, or filtering, or returns picker focus to the last bound text pane; it leaves the comparison open. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias.
Both diff panes apply available semantic tokens from their own buffer, independently of keyboard focus. Historical snapshots never borrow the working buffer's tokens; live After tokens must match the displayed text revision. Missing or invalidated tokens leave Tree-sitter syntax colors intact. Semantic colors and modifiers preserve the change backgrounds. Both diff panes apply available semantic tokens from their own buffer, independently of keyboard focus. Historical snapshots never borrow the working buffer's tokens. Typing and undo/redo retain semantic colors outside the edited range, shifting their positions to follow the text until a current LSP reply replaces them. Edited ranges fall back to Tree-sitter syntax colors while waiting; semantic roles elsewhere can briefly lag changes. Semantic colors and modifiers preserve the change backgrounds.
Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file. Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file.
Large comparisons load each selected side independently, up to 64 MiB and 4,000,000 source lines per side. Frozen Working snapshots have a 512 MiB aggregate budget. Exceeding a limit displays the actual error in the document pane. Large files use the normal plain-text syntax fallback; pathological comparisons may show broader replacement regions or whole-line highlights rather than block input searching for a minimal diff. Full source text remains available.
## Jujutsu conflict resolution
Run **`:resolve`** in Normal mode for the current jj working copy. It opens a dedicated tab and selects the current conflicted file (otherwise the first conflict). Historical comparisons remain read-only; resolving never implicitly checks out or creates another change.
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.
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 |
| --- | --- |
| `resolve.next` / `resolve.previous` | Jump between differing decision hunks |
| `resolve.source [N]` | Select/focus an Alternative; in History, select an outer input; without N, cycle |
| `resolve.base [N]` | Open History and select a numbered base; default first base |
| `resolve.use_source [N]` | Take the current differing hunk from that Alternative as one undoable edit |
| `resolve.next_file` / `resolve.previous_file` | Open another retained file |
| `resolve.files` / `resolve.draft` | Toggle/focus the file explorer / return to the editable Draft |
| `resolve.history` / `resolve.view.toggle` | Show read-only input/base/input History / switch views |
| `resolve.close` | Close the resolver without discarding dirty drafts |
**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.
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.
Binary, symlink, deletion, executable-bit and file/directory conflicts use explicit whole-file input choices: choose an input with `resolve.use_source`, then save to apply its exact bytes and type. Symlinks are never followed. Directory replacement refuses unrelated/untracked or modified child data. Git submodule choices and legacy jj conflict storage without `jj:trees` metadata report unsupported errors instead of fabricating a resolution.
Opening, navigating and choosing draft resolutions do not snapshot jj; only saving does. If another jj operation changes the frozen checkout, retain your draft, close after preserving it, and reopen the resolver against the new state.
## Command palette ## Command palette
Ctrl+p opens a searchable overlay without leaving the current mode. It lists each core action once, plus registered plugin commands, with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear. Ctrl+p opens a searchable overlay without leaving the current mode. It lists each core action once, plus registered plugin commands, with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear.
@@ -447,7 +603,7 @@ Space begins a leader sequence in Normal/Visual by default. Explicit sequences s
## Jump history ## Jump history
Normal Ctrl+o moves back through previous locations; Ctrl+i moves forward. Traditional terminals encode Ctrl+i as Tab, so Normal Tab performs the same action outside comparisons while Insert Tab inserts text outside snippets. In a comparison, Ctrl+i/Tab retains its diff-side focus behavior; use the **Jump forward** palette action or another configured key such as `config.bind "ctrl+y", config.core.jump_forward`. Counts work in either direction, such as `3` followed by Ctrl+o, and stop at the available history. Normal Ctrl+o moves back through previous locations; Ctrl+i moves forward, including in comparisons. Normal Tab also jumps forward outside comparisons, but cycles panes within comparisons; Shift+Tab cycles backward. The Unix TUI requests disambiguated keyboard events so supporting terminals preserve this distinction. Legacy terminals encode Ctrl+i as Tab and cannot distinguish them; there, use the **Jump forward** palette action or another configured key such as `config.bind "ctrl+y", config.core.jump_forward`. Counts work in either direction, such as `3` followed by Ctrl+o, and stop at the available history.
The session keeps up to 100 primary-caret locations with their tab, pane, and comparison context, not just buffer coordinates. Successful Markdown links, accepted LSP definitions, file-picker opens, `:edit`, `:buffer`, `:bnext`/`:bprev`, and search `/`/`n`/`N` jumps record locations. Back/forward restores the original view, including its comparison file and side; an external LSP definition round-trip does not replace the comparison with an ordinary buffer. Identical buffer/cursor positions in different views remain distinct. Ordinary cursor/word motion, edits, failed navigation, manual tab/pane selection, and jumps that do not change location do not add entries. Traversal itself does not record another jump. The session keeps up to 100 primary-caret locations with their tab, pane, and comparison context, not just buffer coordinates. Successful Markdown links, accepted LSP definitions, file-picker opens, `:edit`, `:buffer`, `:bnext`/`:bprev`, and search `/`/`n`/`N` jumps record locations. Back/forward restores the original view, including its comparison file and side; an external LSP definition round-trip does not replace the comparison with an ordinary buffer. Identical buffer/cursor positions in different views remain distinct. Ordinary cursor/word motion, edits, failed navigation, manual tab/pane selection, and jumps that do not change location do not add entries. Traversal itself does not record another jump.
@@ -473,10 +629,16 @@ 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++, and Go. 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.
Colors retain the terminal palette: comments dark gray, strings green, keywords magenta, numbers yellow, types/markup cyan, and functions light blue. Grammar queries handle multiline/nested constructs, even when their opening is above the viewport. Markdown uses its inline grammar and named fenced-code injections; HTML script/style content uses JavaScript/CSS. Known tagged-template and raw-string injections use the same selected grammar registry; unknown injected languages retain host highlighting or plain text. Highlighting styles whole graphemes and expanded tab cells; partially clipped wide placeholders remain unchanged. Search, Visual selections, and secondary carets retain foreground/background precedence. Colors retain the terminal palette: comments dark gray, strings green, keywords magenta, numbers yellow, types/markup cyan, and functions light blue. Grammar queries handle multiline/nested constructs, even when their opening is above the viewport. Markdown uses its inline grammar and named fenced-code injections; HTML script/style content uses JavaScript/CSS. Known tagged-template and raw-string injections use the same selected grammar registry; unknown injected languages retain host highlighting or plain text. Highlighting styles whole graphemes and expanded tab cells; partially clipped wide placeholders remain unchanged. Search, Visual selections, and secondary carets retain foreground/background precedence.
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. Tree-sitter fills gaps and remains available while new tokens are pending after edits. 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.
+10 -3
View File
@@ -6,18 +6,24 @@ Vamp.configure do |config|
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.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.highlight_trailing_whitespace = false # true marks line-end whitespace in red config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\n" => "↵" } config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\n" => "↵" }
config.whitespace_scope = "all" # or "leading" for indentation only; newline markers remain independent config.whitespace_scope = "all" # or "leading" for indentation only; newline markers remain independent
config.tab_spaces = nil # opt in: 4 inserts four spaces per Tab
# config.language_tab_spaces.merge!("ruby" => 2, "makefile" => nil)
config.file_explorer_side = "right" config.file_explorer_side = "right"
config.file_explorer_view = "tree" config.file_explorer_view = "tree"
config.file_picker_view = "tree" config.file_picker_view = "tree"
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.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],
@@ -25,9 +31,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: "" }
@@ -39,6 +45,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: {}
+34
View File
@@ -0,0 +1,34 @@
Greeting: Hello
Shared context 01
Shared context 02
Shared context 03
Shared context 04
Shared context 05
Shared context 06
Shared context 07
Shared context 08
Shared context 09
Shared context 10
Shared context 11
Shared context 12
Shared context 13
Shared context 14
Shared context 15
Shared context 16
Shared context 17
Shared context 18
Shared context 19
Shared context 20
Shared context 21
Shared context 22
Shared context 23
Shared context 24
Shared context 25
Shared context 26
Shared context 27
Shared context 28
Shared context 29
Shared context 30
Farewell: Goodbye
+1
View File
@@ -0,0 +1 @@
Delivery: send the notification immediately
+1
View File
@@ -0,0 +1 @@
greeting.txt
+3 -229
View File
@@ -137,8 +137,6 @@ mod macos {
fn CFBooleanGetValue(boolean: Object) -> u8; fn CFBooleanGetValue(boolean: Object) -> u8;
fn CFNumberGetTypeID() -> usize; fn CFNumberGetTypeID() -> usize;
fn CFNumberGetValue(number: Object, kind: isize, value: *mut c_void) -> u8; fn CFNumberGetValue(number: Object, kind: isize, value: *mut c_void) -> u8;
#[cfg(test)]
fn CFNumberCreate(allocator: Object, kind: isize, value: Object) -> Object;
} }
// Only Copy/Create results belong here; power source descriptions are borrowed. // Only Copy/Create results belong here; power source descriptions are borrowed.
@@ -219,34 +217,7 @@ mod macos {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; include!("../tests/unit/battery_macos.rs");
#[test]
fn native_values_are_type_checked_and_numbers_must_be_exact_and_nonnegative() {
assert!(Owned::new(std::ptr::null()).is_none());
unsafe {
assert_eq!(number(std::ptr::null()), None);
let text = Owned::string(c"27").unwrap();
assert_eq!(number(text.0), None);
for value in [-1_i64, 0, 100] {
let object = Owned::new(CFNumberCreate(
std::ptr::null(),
4,
(&value as *const i64).cast(),
))
.unwrap();
assert_eq!(number(object.0), u64::try_from(value).ok());
}
let fraction = 1.5_f64;
let object = Owned::new(CFNumberCreate(
std::ptr::null(),
6,
(&fraction as *const f64).cast(),
))
.unwrap();
assert_eq!(number(object.0), None);
}
}
} }
} }
@@ -341,202 +312,5 @@ mod windows {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "../tests/unit/battery.rs"]
use super::*; mod tests;
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicBool, AtomicU64, Ordering},
};
#[test]
fn percentages_reject_invalid_data_and_include_both_endpoints() {
assert_eq!(percentage(0, 100), Some(0));
assert_eq!(percentage(100, 100), Some(100));
assert_eq!(percentage(1, 3), Some(33));
assert_eq!(percentage(u64::MAX, u64::MAX), Some(100));
assert_eq!(percentage(0, 0), None);
assert_eq!(percentage(101, 100), None);
assert_eq!(windows_charge(0, 4), Some(0));
assert_eq!(windows_charge(100, 8), Some(100));
assert_eq!(windows_charge(101, 0), None);
assert_eq!(windows_charge(255, 0), None);
assert_eq!(windows_charge(50, 128), None);
assert_eq!(windows_charge(50, 255), None);
}
fn worker(samples: Vec<Option<u8>>) -> (Battery, Receiver<()>, Arc<AtomicBool>) {
struct ReaderDropped(Arc<AtomicBool>);
impl Drop for ReaderDropped {
fn drop(&mut self) {
self.0.store(true, Ordering::Release);
}
}
let dropped = Arc::new(AtomicBool::new(false));
let reader = ReaderDropped(Arc::clone(&dropped));
let mut samples = samples.into_iter();
let (ready, published) = mpsc::channel();
let battery = Battery::start_with(
move || {
let _ = &reader;
samples
.next()
.expect("test provides every requested sample")
},
move |wake| {
ready.send(()).unwrap();
// Explicit channel messages replace the production clock entirely.
wake.recv().is_ok()
},
)
.unwrap();
(battery, published, dropped)
}
#[test]
fn worker_reads_immediately_replaces_stale_samples_and_clears_invalid_data() {
let (mut battery, published, dropped) = worker(vec![Some(0), Some(100), None, Some(101)]);
published.recv().unwrap();
assert_eq!(battery.poll(), Some(Some(0)));
assert_eq!(battery.poll(), None);
battery.wake.as_ref().unwrap().send(()).unwrap();
published.recv().unwrap();
battery.wake.as_ref().unwrap().send(()).unwrap();
published.recv().unwrap();
assert_eq!(battery.poll(), Some(None));
assert_eq!(battery.poll(), None);
battery.wake.as_ref().unwrap().send(()).unwrap();
published.recv().unwrap();
assert_eq!(battery.poll(), Some(None));
drop(battery);
assert!(dropped.load(Ordering::Acquire));
}
#[test]
fn dropping_interrupts_wait_and_joins_even_with_an_unread_sample() {
let (battery, published, dropped) = worker(vec![Some(50)]);
published.recv().unwrap();
drop(battery);
assert!(dropped.load(Ordering::Acquire));
}
static FIXTURE_ID: AtomicU64 = AtomicU64::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"vamp-battery-{}-{}",
std::process::id(),
FIXTURE_ID.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&path).unwrap();
Self(path)
}
fn source(&self, name: &str, fields: &[(&str, &str)]) {
let path = self.0.join(name);
fs::create_dir_all(&path).unwrap();
for (key, value) in fields {
fs::write(path.join(key), value).unwrap();
}
}
fn read(&self) -> Option<u8> {
linux::read_charge(&self.0)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).unwrap();
}
}
#[test]
fn sysfs_reads_present_system_batteries_but_not_external_or_absent_sources() {
let fixture = Fixture::new();
assert_eq!(fixture.read(), None);
fixture.source("AC", &[("type", "Mains"), ("capacity", "100")]);
fixture.source(
"mouse",
&[
("type", "Battery"),
("scope", "Device"),
("present", "1"),
("capacity", "100"),
],
);
fixture.source(
"absent",
&[("type", "Battery"), ("present", "0"), ("capacity", "100")],
);
assert_eq!(fixture.read(), None);
fixture.source(
"BAT0",
&[
("type", "Battery\n"),
("scope", "System\n"),
("present", "1\n"),
("capacity", "27\n"),
],
);
assert_eq!(fixture.read(), Some(27));
fixture.source("BAT0", &[("capacity", "0")]);
assert_eq!(fixture.read(), Some(0));
fixture.source("BAT0", &[("capacity", "100")]);
assert_eq!(fixture.read(), Some(100));
for invalid in ["101", "-1", "255", "garbage", ""] {
fixture.source("BAT0", &[("capacity", invalid)]);
assert_eq!(fixture.read(), None);
}
fs::remove_file(fixture.0.join("BAT0/capacity")).unwrap();
assert_eq!(fixture.read(), None);
}
#[test]
fn sysfs_uses_matching_actual_energy_or_charge_totals_only() {
let energy = Fixture::new();
energy.source(
"BAT0",
&[
("type", "Battery"),
("present", "1"),
("energy_now", "27"),
("energy_full", "100"),
],
);
assert_eq!(energy.read(), Some(27));
energy.source("BAT0", &[("energy_full", "0")]);
assert_eq!(energy.read(), None);
energy.source("BAT0", &[("energy_full", "26")]);
assert_eq!(energy.read(), None);
energy.source("BAT0", &[("energy_full", "garbage")]);
assert_eq!(energy.read(), None);
let charge = Fixture::new();
charge.source(
"BAT0",
&[
("type", "Battery"),
("present", "1"),
("charge_now", "15"),
("charge_full", "20"),
],
);
assert_eq!(charge.read(), Some(75));
charge.source("BAT0", &[("energy_now", "5")]);
assert_eq!(charge.read(), None);
let design = Fixture::new();
design.source(
"BAT0",
&[
("type", "Battery"),
("present", "1"),
("energy_now", "20"),
("energy_full_design", "100"),
],
);
assert_eq!(design.read(), None);
}
}
+121 -428
View File
@@ -13,11 +13,11 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
const LIMIT: usize = 16 * 1024 * 1024; 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,
use std::{ffi::OsString, os::unix::ffi::OsStringExt}; cancelled: &dyn Fn() -> bool,
Ok(PathBuf::from(OsString::from_vec(bytes.to_vec()))) ) -> ResultFile {
if contents.len() > LIMIT {
return Err("Blame input exceeds 16 MiB".into());
} }
#[cfg(not(unix))] if !oid(&source.commit) {
{ return Err("Invalid blame commit".into());
Ok(PathBuf::from(
std::str::from_utf8(bytes).map_err(|_| "Invalid repository path")?,
))
} }
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())
{
return Ok(None);
}
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,
let store = repo.join("store"); },
let target = fs::read(store.join("git_target")) contents,
.map_err(|_| "Only Git-backed Jujutsu blame is supported")?; cancelled,
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 present = git(root);
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);
@@ -505,377 +570,5 @@ fn parse(bytes: &[u8], rows: usize) -> Result<Vec<Option<Arc<LineBlame>>>, Strin
} }
#[cfg(all(test, unix))] #[cfg(all(test, unix))]
mod tests { #[path = "../tests/unit/blame.rs"]
use super::*; mod tests;
fn command(root: &Path, program: &str, args: &[&str]) -> String {
let mut command = Command::new(program);
if program == "jj" {
command.args([
"--config=user.name=\"Jj Author\"",
"--config=user.email=\"jj@example.test\"",
]);
command.args([
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
]);
}
let output = command
.args(args)
.current_dir(root)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
.env("JJ_CONFIG", "/dev/null")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().into()
}
fn fixture() -> (Temporary, PathBuf, String) {
let temp = Temporary::new().unwrap();
command(&temp.0, "git", &["init", "--quiet", "--template="]);
command(&temp.0, "git", &["config", "user.name", "Blame Author"]);
command(
&temp.0,
"git",
&["config", "user.email", "blame@example.test"],
);
let path = temp.0.join("-literal [file].txt");
fs::write(&path, "first\nsecond\n").unwrap();
command(&temp.0, "git", &["add", "--", "-literal [file].txt"]);
command(
&temp.0,
"git",
&["commit", "--quiet", "-m", "Original summary"],
);
let commit = command(&temp.0, "git", &["rev-parse", "HEAD"]);
(temp, path, commit)
}
#[test]
fn committed_line_has_native_author_summary_and_commit() {
let (_temp, path, commit) = fixture();
let rows = read_file(&path, "first\nsecond\n", &|| false)
.unwrap()
.unwrap();
assert_eq!(rows.len(), 2);
assert!(Arc::ptr_eq(
rows[0].as_ref().unwrap(),
rows[1].as_ref().unwrap()
));
assert_eq!(
**rows[1].as_ref().unwrap(),
LineBlame {
commit,
author: "Blame Author".into(),
summary: "Original summary".into(),
uncommitted: false
}
);
}
#[test]
fn unsaved_changed_line_is_uncommitted_without_metadata() {
let (_temp, path, _) = fixture();
let rows = read_file(&path, "changed\nsecond\n", &|| false)
.unwrap()
.unwrap();
assert_eq!(
**rows[0].as_ref().unwrap(),
LineBlame {
commit: String::new(),
author: String::new(),
summary: String::new(),
uncommitted: true
}
);
assert_eq!(fs::read_to_string(path).unwrap(), "first\nsecond\n");
}
#[test]
fn inserted_line_preserves_following_original_attribution() {
let (_temp, path, commit) = fixture();
let contents = "inserted\nfirst\nsecond\n";
let rows = read_file(&path, contents, &|| false).unwrap().unwrap();
assert!(rows[0].as_ref().unwrap().uncommitted);
assert_eq!(rows[2].as_ref().unwrap().commit, commit);
assert_eq!(rows.len(), 3);
assert_eq!(read_file(&path, "", &|| false).unwrap(), None);
}
#[test]
fn unsaved_insertions_and_deletions_keep_shifted_commit_attribution() {
let (temp, path, original) = fixture();
fs::write(&path, "first\nmiddle\nsecond\ntail\n").unwrap();
command(&temp.0, "git", &["add", "--", "-literal [file].txt"]);
command(&temp.0, "git", &["commit", "--quiet", "-m", "Added lines"]);
let added = command(&temp.0, "git", &["rev-parse", "HEAD"]);
let rows = read_file(
&path,
"unsaved\nfirst\nsecond\nunsaved tail\ntail\n",
&|| false,
)
.unwrap()
.unwrap();
assert!(rows[0].as_ref().unwrap().uncommitted);
assert_eq!(rows[1].as_ref().unwrap().commit, original);
assert_eq!(rows[2].as_ref().unwrap().commit, original);
assert!(Arc::ptr_eq(
rows[1].as_ref().unwrap(),
rows[2].as_ref().unwrap()
));
assert!(rows[3].as_ref().unwrap().uncommitted);
assert!(Arc::ptr_eq(
rows[0].as_ref().unwrap(),
rows[3].as_ref().unwrap()
));
assert_eq!(rows[4].as_ref().unwrap().commit, added);
assert_eq!(rows[4].as_ref().unwrap().summary, "Added lines");
assert_eq!(
fs::read_to_string(path).unwrap(),
"first\nmiddle\nsecond\ntail\n"
);
}
#[test]
fn missing_untracked_unborn_nonrepo_and_symlink_have_no_blame() {
let temp = Temporary::new().unwrap();
let path = temp.0.join("file");
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
fs::write(&path, "text").unwrap();
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
command(&temp.0, "git", &["init", "--quiet", "--template="]);
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
let (_committed, tracked, _) = fixture();
let untracked = tracked.parent().unwrap().join("untracked");
fs::write(&untracked, "text").unwrap();
assert_eq!(read_file(&untracked, "text", &|| false).unwrap(), None);
let link = temp.0.join("link");
std::os::unix::fs::symlink(tracked, &link).unwrap();
assert_eq!(
read_file(&link, "first\nsecond\n", &|| false).unwrap(),
None
);
}
#[test]
fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() {
let temp = Temporary::new().unwrap();
command(&temp.0, "jj", &["git", "init", "--no-colocate", "."]);
let path = temp.0.join("file");
fs::write(&path, "original\n").unwrap();
assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None);
command(
&temp.0,
"jj",
&[
"--config=user.name=\"Jj Author\"",
"--config=user.email=\"jj@example.test\"",
"describe",
"-m",
"Jj summary",
],
);
command(
&temp.0,
"jj",
&[
"--config=user.name=\"Jj Author\"",
"--config=user.email=\"jj@example.test\"",
"new",
],
);
let parent = command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"log",
"--no-graph",
"-r",
"@-",
"-T",
"commit_id",
],
);
let operation = command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id",
],
);
let rows = read_file(&path, "original\n", &|| false).unwrap().unwrap();
let line = rows[0].as_ref().unwrap();
assert_eq!(line.commit, parent);
assert_eq!(line.author, "Jj Author");
assert_eq!(line.summary, "Jj summary");
fs::write(&path, "dirty on disk\n").unwrap();
let shifted = read_file(&path, "inserted\noriginal\n", &|| false)
.unwrap()
.unwrap();
assert!(shifted[0].as_ref().unwrap().uncommitted);
assert_eq!(shifted[1].as_ref().unwrap().commit, parent);
assert!(
read_file(&path, "unsaved\n", &|| false).unwrap().unwrap()[0]
.as_ref()
.unwrap()
.uncommitted
);
assert_eq!(
command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id"
]
),
operation
);
assert!(!temp.0.join(".git").exists());
command(&temp.0, "jj", &["new", "root()"]);
fs::write(temp.0.join("other"), "sibling\n").unwrap();
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
command(&temp.0, "jj", &["new", &parent, "@"]);
assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None);
}
#[test]
fn incremental_groups_share_metadata_across_ranges_and_repeated_commits() {
let first = "1111111111111111111111111111111111111111";
let second = "2222222222222222222222222222222222222222";
let output = format!(
"{first} 5 3 2\nauthor First\nsummary First summary\nfilename file\n\
{second} 8 2 1\nauthor Second\nsummary Second summary\nfilename file\n\
{first} 1 1 1\nfilename old-file\n"
);
let rows = parse(output.as_bytes(), 4).unwrap();
assert_eq!(rows[0].as_ref().unwrap().commit, first);
assert_eq!(rows[1].as_ref().unwrap().author, "Second");
assert_eq!(rows[1].as_ref().unwrap().commit, second);
assert!(Arc::ptr_eq(
rows[0].as_ref().unwrap(),
rows[2].as_ref().unwrap()
));
assert!(Arc::ptr_eq(
rows[2].as_ref().unwrap(),
rows[3].as_ref().unwrap()
));
}
#[test]
fn malformed_incremental_groups_never_echo_metadata() {
let commit = "1111111111111111111111111111111111111111";
let metadata = "author secret\nsummary secret\nfilename secret\n";
let valid = format!("{commit} 1 1 1\n{metadata}");
for output in [
"secret metadata".into(),
"\u{fffd}secret metadata".into(),
format!("{commit} 1 2 1\n{metadata}"),
format!("{commit} 1 1 2\n{metadata}"),
format!("{commit} 0 1 1\n{metadata}"),
format!("{commit} 1 1 0\n{metadata}"),
format!("{commit} 1 1 1 extra\n{metadata}"),
format!("{commit} 1 1 1\nauthor secret\nfilename secret\n"),
format!(
"{commit} 1 1 1\nauthor secret\nauthor secret\nsummary secret\nfilename secret\n"
),
format!("{commit} 1 1 1\nauthor secret\nsummary secret\n\tsecret source\n"),
format!("{valid}{commit} 2 1 1\nfilename secret\n"),
format!(
"{commit} 1 1 1\nauthor secret\nsummary secret\n{commit} 2 1 1\nfilename secret\n"
),
format!("{commit} {} 1 2\n{metadata}", usize::MAX),
] {
assert_eq!(
parse(output.as_bytes(), 1).unwrap_err(),
"Invalid Git blame metadata"
);
}
assert_eq!(
parse(valid.as_bytes(), 2).unwrap_err(),
"Invalid Git blame metadata"
);
assert_eq!(
parse(b"\xffsecret metadata", 1).unwrap_err(),
"Invalid Git blame metadata"
);
}
#[test]
fn cancelled_commands_kill_and_reap_pipe_holding_children() {
let temp = Temporary::new().unwrap();
let ready = temp.0.join("ready");
let cancellation = Arc::new(AtomicBool::new(false));
let stop = Arc::clone(&cancellation);
let gate = ready.clone();
let worker = thread::spawn(move || {
let mut command = Command::new("sh");
command
.args([
"-c",
"sleep 600 & printf ready > \"$1\"; wait",
"vamp-blame-test",
])
.arg(gate);
run(command, &[], &|| stop.load(Ordering::Acquire))
});
while !ready.exists() && !worker.is_finished() {
thread::yield_now();
}
assert!(ready.exists());
cancellation.store(true, Ordering::Release);
assert_eq!(worker.join().unwrap().unwrap_err(), "Blame cancelled");
}
#[test]
fn worker_keeps_only_latest_request_and_bounds_input() {
let (_temp, path, commit) = fixture();
let mut worker = BlameWorker::start().unwrap();
for id in 0..20 {
worker
.request(id, path.clone(), "first\nsecond\n".into())
.unwrap();
}
loop {
if let Some((id, result)) = worker.poll() {
assert_eq!(id, 19);
let rows = result.unwrap().unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[1].as_ref().unwrap().commit, commit);
assert!(Arc::ptr_eq(
rows[0].as_ref().unwrap(),
rows[1].as_ref().unwrap()
));
break;
}
thread::yield_now();
}
assert_eq!(
worker
.request(20, path, "x".repeat(LIMIT + 1))
.unwrap_err()
.kind(),
io::ErrorKind::InvalidInput
);
}
#[test]
fn subprocess_output_is_bounded() {
let mut command = Command::new("sh");
command.args(["-c", "dd if=/dev/zero bs=1048576 count=17 2>/dev/null"]);
assert_eq!(
run(command, &[], &|| false).unwrap_err(),
"Blame output exceeds 16 MiB"
);
}
}
+21 -209
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..];
@@ -363,185 +355,5 @@ pub fn labels<'de, D: Deserializer<'de>>(
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "../tests/unit/chrome.rs"]
use super::*; mod tests;
#[test]
fn popup_partial_options_keep_command_and_search_independent() {
let popup: PopupConfig = serde_json::from_value(serde_json::json!({
"command": {"anchor": "top_right"}
}))
.unwrap();
assert_eq!(popup.command.anchor, PopupAnchor::TopRight);
assert_eq!(popup.search.anchor, PopupAnchor::BottomLeft);
let popup: PopupConfig = serde_json::from_value(serde_json::json!({
"command": {}, "search": {"anchor": "center_left"}
}))
.unwrap();
assert_eq!(popup.command.anchor, PopupAnchor::BottomLeft);
assert_eq!(popup.search.anchor, PopupAnchor::CenterLeft);
}
#[test]
fn malformed_popup_options_are_rejected_at_the_boundary() {
for value in [
serde_json::json!(null),
serde_json::json!([]),
serde_json::json!(7),
serde_json::json!({"selector": {}}),
] {
assert!(
serde_json::from_value::<PopupConfig>(value.clone()).is_err(),
"{value}"
);
}
for options in [
serde_json::json!(null),
serde_json::json!([]),
serde_json::json!("center"),
serde_json::json!({"anchor": null}),
serde_json::json!({"anchor": 7}),
serde_json::json!({"anchor": true}),
serde_json::json!({"anchor": []}),
serde_json::json!({"anchor": {}}),
serde_json::json!({"anchor": "middle"}),
serde_json::json!({"anchor": "TopLeft"}),
serde_json::json!({"width": 72}),
serde_json::json!({"anchor": "center", "callback": "run"}),
] {
for mode in ["command", "search"] {
let mut popup = serde_json::json!({});
popup[mode] = options.clone();
assert!(
serde_json::from_value::<PopupConfig>(popup).is_err(),
"{mode}: {options}"
);
}
}
}
#[derive(Deserialize)]
struct Lines {
#[serde(deserialize_with = "top")]
top: ChromeLine,
#[serde(deserialize_with = "bottom")]
bottom: ChromeLine,
}
#[test]
fn omitted_options_inherit_and_explicit_groups_replace_defaults() {
let lines: Lines = serde_json::from_value(serde_json::json!({
"top": {"right": ["battery"], "tabs": {"title": "T{index}:{path}{modified}"}, "battery": {"padding": 0}},
"bottom": {"left": [], "center": ["workspace"]}
})).unwrap();
assert_eq!(lines.top.left, ["tabs"]);
assert_eq!(lines.top.segment("battery").padding, 0);
assert!(lines.bottom.left.is_empty());
assert_eq!(lines.bottom.right, ["language", "lsp", "position"]);
assert_eq!(lines.bottom.segment("workspace"), &SegmentConfig::default());
}
#[test]
fn both_positions_accept_all_builtins_and_custom_styles() {
let names = [
"tabs",
"mode",
"pending",
"buffer",
"diagnostics",
"path",
"selections",
"count",
"language",
"lsp",
"position",
"battery",
"workspace",
];
let options = serde_json::json!({
"left": names,
"center": ["language"],
"right": ["tabs"],
"tabs": {"title": "{index}:{name}:{path}{modified}", "padding": 0},
"mode": {"leading_edge": "<", "trailing_edge": ">"},
"workspace": {"first_leading_edge": ""}
});
let lines: Lines = serde_json::from_value(serde_json::json!({
"top": options,
"bottom": options,
}))
.unwrap();
for line in [&lines.top, &lines.bottom] {
assert_eq!(line.left, names);
assert_eq!(line.center, ["language"]);
assert_eq!(line.right, ["tabs"]);
assert_eq!(line.segment("tabs").padding, 0);
assert_eq!(line.segment("mode").leading_edge, "<");
assert_eq!(
line.segment("workspace").first_leading_edge.as_deref(),
Some("")
);
}
}
#[test]
fn view_placeholder_is_valid_in_every_segment_and_bar() {
for name in [
"tabs",
"mode",
"pending",
"buffer",
"diagnostics",
"path",
"selections",
"count",
"language",
"lsp",
"position",
"battery",
"workspace",
] {
let options = serde_json::json!({
"left": [name],
(name): {"title": "{index}:{view}{name}:{path}{modified}"}
});
let lines: Lines = serde_json::from_value(serde_json::json!({
"top": options,
"bottom": options,
}))
.unwrap();
for line in [&lines.top, &lines.bottom] {
assert_eq!(
line.segment(name).title,
"{index}:{view}{name}:{path}{modified}"
);
}
}
}
#[test]
fn malformed_chrome_is_rejected_at_the_boundary() {
for options in [
serde_json::json!({"left": ["bad-name"]}),
serde_json::json!({"left": null}),
serde_json::json!({"tabs": {"padding": 65}}),
serde_json::json!({"tabs": {"padding": null}}),
serde_json::json!({"tabs": {"margin": 65}}),
serde_json::json!({"tabs": {"margin": -1}}),
serde_json::json!({"tabs": {"margin": null}}),
serde_json::json!({"tabs": {"margin": "1"}}),
serde_json::json!({"tabs": {"margin": 1.5}}),
serde_json::json!({"tabs": {"leading_edge": "\n"}}),
serde_json::json!({"tabs": {"title": "{unknown}"}}),
serde_json::json!({"tabs": {"title": "{name"}}),
serde_json::json!({"tabs": {"title": "name}"}}),
serde_json::json!({"tabs": {"colour": "red"}}),
] {
for position in ["top", "bottom"] {
let mut frame = serde_json::json!({"top": {}, "bottom": {}});
frame[position] = options.clone();
assert!(serde_json::from_value::<Lines>(frame).is_err());
}
}
}
}
+101 -286
View File
@@ -4,17 +4,6 @@ use std::time::Duration;
const MAX_CLIPBOARD_BYTES: usize = 4 * 1024 * 1024; const MAX_CLIPBOARD_BYTES: usize = 4 * 1024 * 1024;
const COMMAND_TIMEOUT: Duration = Duration::from_secs(2); const COMMAND_TIMEOUT: Duration = Duration::from_secs(2);
#[cfg(target_os = "macos")]
const MAC_READ: &str = r#"
ObjC.import('AppKit');
function run() {
var text = $.NSPasteboard.generalPasteboard.stringForType($.NSPasteboardTypeString);
if (text.isNil()) return;
$.NSFileHandle.fileHandleWithStandardOutput.writeData(
text.dataUsingEncoding($.NSUTF8StringEncoding));
}
"#;
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ClipboardMode { pub enum ClipboardMode {
System, System,
@@ -27,7 +16,7 @@ struct ClipboardCommand {
} }
struct Commands { struct Commands {
reader: ClipboardCommand, reader: Option<ClipboardCommand>,
writer: Option<ClipboardCommand>, writer: Option<ClipboardCommand>,
} }
@@ -54,10 +43,10 @@ impl SystemClipboard {
pub(crate) fn with_commands(reader: PathBuf, writer: PathBuf) -> Self { pub(crate) fn with_commands(reader: PathBuf, writer: PathBuf) -> Self {
Self { Self {
commands: Some(Ok(Commands { commands: Some(Ok(Commands {
reader: ClipboardCommand { reader: Some(ClipboardCommand {
program: reader, program: reader,
args: &[], args: &[],
}, }),
writer: Some(ClipboardCommand { writer: Some(ClipboardCommand {
program: writer, program: writer,
args: &[], args: &[],
@@ -70,9 +59,16 @@ impl SystemClipboard {
pub fn read(&mut self) -> Result<String, String> { pub fn read(&mut self) -> Result<String, String> {
let timeout = self.timeout; let timeout = self.timeout;
let commands = self.commands.get_or_insert_with(select_commands); let commands = self.commands.get_or_insert_with(select_commands);
let command = &commands.as_ref().map_err(Clone::clone)?.reader; match &commands.as_ref().map_err(Clone::clone)?.reader {
let bytes = run(command, None, timeout)?; Some(command) => {
String::from_utf8(bytes).map_err(|_| "System clipboard is not valid UTF-8".into()) let bytes = run(command, None, timeout)?;
String::from_utf8(bytes).map_err(|_| "System clipboard is not valid UTF-8".into())
}
#[cfg(target_os = "macos")]
None => macos::read(),
#[cfg(not(target_os = "macos"))]
None => Err("System clipboard is unsupported on this platform".into()),
}
} }
pub fn write(&mut self, text: &str) -> Result<(), String> { pub fn write(&mut self, text: &str) -> Result<(), String> {
@@ -91,7 +87,7 @@ impl SystemClipboard {
} }
} }
#[cfg(any(target_os = "macos", target_os = "linux"))] #[cfg(target_os = "linux")]
fn executable(name: &str) -> Option<PathBuf> { fn executable(name: &str) -> Option<PathBuf> {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
@@ -109,10 +105,7 @@ fn select_commands() -> Result<Commands, String> {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
Ok(Commands { Ok(Commands {
reader: ClipboardCommand { reader: None,
program: executable("osascript").unwrap_or_else(|| "/usr/bin/osascript".into()),
args: &["-l", "JavaScript", "-e", MAC_READ],
},
writer: None, writer: None,
}) })
} }
@@ -121,10 +114,10 @@ fn select_commands() -> Result<Commands, String> {
if std::env::var_os("WAYLAND_DISPLAY").is_some_and(|value| !value.is_empty()) { if std::env::var_os("WAYLAND_DISPLAY").is_some_and(|value| !value.is_empty()) {
if let (Some(reader), Some(writer)) = (executable("wl-paste"), executable("wl-copy")) { if let (Some(reader), Some(writer)) = (executable("wl-paste"), executable("wl-copy")) {
return Ok(Commands { return Ok(Commands {
reader: ClipboardCommand { reader: Some(ClipboardCommand {
program: reader, program: reader,
args: &["--no-newline", "--type", "text"], args: &["--no-newline", "--type", "text"],
}, }),
writer: Some(ClipboardCommand { writer: Some(ClipboardCommand {
program: writer, program: writer,
args: &["--type", "text/plain;charset=utf-8"], args: &["--type", "text/plain;charset=utf-8"],
@@ -135,10 +128,10 @@ fn select_commands() -> Result<Commands, String> {
if std::env::var_os("DISPLAY").is_some_and(|value| !value.is_empty()) { if std::env::var_os("DISPLAY").is_some_and(|value| !value.is_empty()) {
if let Some(program) = executable("xclip") { if let Some(program) = executable("xclip") {
return Ok(Commands { return Ok(Commands {
reader: ClipboardCommand { reader: Some(ClipboardCommand {
program: program.clone(), program: program.clone(),
args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-out"], args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-out"],
}, }),
writer: Some(ClipboardCommand { writer: Some(ClipboardCommand {
program, program,
args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-in"], args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-in"],
@@ -250,74 +243,88 @@ mod macos {
write_to(name.0, text) write_to(name.0, text)
} }
#[link(name = "AppKit", kind = "framework")]
unsafe extern "C" {
static NSPasteboardTypeString: Object;
}
#[link(name = "objc")]
unsafe extern "C" {
fn objc_getClass(name: *const c_char) -> Object;
fn sel_registerName(name: *const c_char) -> Object;
fn objc_msgSend();
fn objc_autoreleasePoolPush() -> Object;
fn objc_autoreleasePoolPop(pool: Object);
}
struct AutoreleasePool(Object);
impl Drop for AutoreleasePool {
fn drop(&mut self) {
// SAFETY: this pool was pushed on the same thread.
unsafe { objc_autoreleasePoolPop(self.0) };
}
}
fn read_from(board: Object) -> Result<String, String> {
// SAFETY: receivers are live AppKit objects; each cast matches the
// selected Objective-C method's ABI. The caller owns an autorelease pool.
unsafe {
let string: unsafe extern "C" fn(Object, Object, Object) -> Object =
std::mem::transmute(objc_msgSend as unsafe extern "C" fn());
let text = string(
board,
sel_registerName(c"stringForType:".as_ptr()),
NSPasteboardTypeString,
);
if text.is_null() {
return Ok(String::new());
}
let encode: unsafe extern "C" fn(Object, Object, usize) -> Object =
std::mem::transmute(objc_msgSend as unsafe extern "C" fn());
let data = encode(text, sel_registerName(c"dataUsingEncoding:".as_ptr()), 4);
if data.is_null() {
return Err("System clipboard is not valid UTF-8".into());
}
let length: unsafe extern "C" fn(Object, Object) -> usize =
std::mem::transmute(objc_msgSend as unsafe extern "C" fn());
let length = length(data, sel_registerName(c"length".as_ptr()));
if length > super::MAX_CLIPBOARD_BYTES {
return Err("System clipboard text exceeds 4 MiB".into());
}
if length == 0 {
return Ok(String::new());
}
let bytes: unsafe extern "C" fn(Object, Object) -> *const u8 =
std::mem::transmute(objc_msgSend as unsafe extern "C" fn());
let bytes = std::slice::from_raw_parts(
bytes(data, sel_registerName(c"bytes".as_ptr())),
length,
);
String::from_utf8(bytes.to_vec())
.map_err(|_| "System clipboard is not valid UTF-8".into())
}
}
pub(super) fn read() -> Result<String, String> {
// SAFETY: AppKit class and selector names are static C strings.
unsafe {
let _pool = AutoreleasePool(objc_autoreleasePoolPush());
let send: unsafe extern "C" fn(Object, Object) -> Object =
std::mem::transmute(objc_msgSend as unsafe extern "C" fn());
let board = send(
objc_getClass(c"NSPasteboard".as_ptr()),
sel_registerName(c"generalPasteboard".as_ptr()),
);
if board.is_null() {
return Err("Cannot open system clipboard".into());
}
read_from(board)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; include!("../tests/unit/clipboard_macos.rs");
unsafe extern "C" {
fn PasteboardSynchronize(pasteboard: Object) -> u32;
fn PasteboardGetItemIdentifier(
pasteboard: Object,
index: isize,
item: *mut usize,
) -> i32;
fn PasteboardCopyItemFlavorData(
pasteboard: Object,
item: usize,
flavor: Object,
data: *mut Object,
) -> i32;
fn CFDataGetBytePtr(data: Object) -> *const u8;
fn CFDataGetLength(data: Object) -> isize;
}
#[test]
fn native_publication_preserves_plain_utf8_bytes_on_an_isolated_pasteboard() {
// A private pasteboard leaves the user's clipboard untouched.
let name = unsafe {
Owned::new(CFStringCreateWithCString(
ptr::null(),
c"vamp-native-clipboard-regression".as_ptr(),
0x0800_0100,
))
.unwrap()
};
let board = unsafe {
let mut board = ptr::null();
check(PasteboardCreate(name.0, &mut board)).unwrap();
Owned::new(board).unwrap()
};
for text in [
"",
"é e\u{301} 👨‍💻\n\0tail",
"{\\rtf1 plain text}",
"%!PS-Adobe-3.0\nplain",
] {
write_to(name.0, text).unwrap();
unsafe {
PasteboardSynchronize(board.0);
let mut item = 0;
check(PasteboardGetItemIdentifier(board.0, 1, &mut item)).unwrap();
let flavor = Owned::new(CFStringCreateWithCString(
ptr::null(),
c"public.utf8-plain-text".as_ptr(),
0x0800_0100,
))
.unwrap();
let mut data = ptr::null();
check(PasteboardCopyItemFlavorData(
board.0, item, flavor.0, &mut data,
))
.unwrap();
let data = Owned::new(data).unwrap();
let length = CFDataGetLength(data.0) as usize;
let bytes = if length == 0 {
&[]
} else {
std::slice::from_raw_parts(CFDataGetBytePtr(data.0), length)
};
assert_eq!(bytes, text.as_bytes());
}
}
}
} }
} }
@@ -534,197 +541,5 @@ unsafe extern "C" {
} }
#[cfg(all(test, any(target_os = "macos", target_os = "linux")))] #[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
mod tests { #[path = "../tests/unit/clipboard.rs"]
use super::*; mod tests;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::sync::atomic::{AtomicU64, Ordering};
static FIXTURE_ID: AtomicU64 = AtomicU64::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"vamp-clipboard-{}-{}",
std::process::id(),
FIXTURE_ID.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&path).unwrap();
Self(path)
}
fn command(&self, name: &str, body: &str) -> PathBuf {
let path = self.0.join(name);
fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
path
}
fn clipboard(&self, text: &[u8]) -> SystemClipboard {
let state = self.0.join("state");
fs::write(&state, text).unwrap();
let state = format!("'{}'", state.to_str().unwrap().replace('\'', "'\\''"));
SystemClipboard::with_commands(
self.command("read", &format!("exec /bin/cat {state}")),
self.command("write", &format!("exec /bin/cat > {state}")),
)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).unwrap();
}
}
#[test]
fn construction_does_not_probe_or_spawn() {
assert!(SystemClipboard::new().commands.is_none());
}
#[test]
fn round_trips_unicode_whitespace_and_large_pipes_exactly() {
let fixture = Fixture::new();
let mut clipboard = fixture.clipboard(b"");
for text in ["", " héllo\t世界🙂\r\n\n", &"é\n".repeat(300_000)] {
clipboard.write(text).unwrap();
assert_eq!(fs::read(fixture.0.join("state")).unwrap(), text.as_bytes());
assert_eq!(clipboard.read().unwrap(), text);
}
}
#[test]
fn rejects_oversize_and_invalid_utf8_without_leaking_contents() {
let fixture = Fixture::new();
let mut clipboard = fixture.clipboard(&[0xff, b'\n']);
assert_eq!(
clipboard.read().unwrap_err(),
"System clipboard is not valid UTF-8"
);
let maximum = "é".repeat(MAX_CLIPBOARD_BYTES / 2);
clipboard.write(&maximum).unwrap();
assert_eq!(clipboard.read().unwrap(), maximum);
let too_large = format!("{maximum}x");
assert_eq!(
clipboard.write(&too_large).unwrap_err(),
"System clipboard text exceeds 4 MiB"
);
assert_eq!(
fs::read(fixture.0.join("state")).unwrap(),
maximum.as_bytes()
);
fs::write(fixture.0.join("state"), &too_large).unwrap();
assert_eq!(
clipboard.read().unwrap_err(),
"System clipboard text exceeds 4 MiB"
);
let mut incomplete = vec![b'x'; MAX_CLIPBOARD_BYTES - 1];
incomplete.push(0xc3);
fs::write(fixture.0.join("state"), incomplete).unwrap();
assert_eq!(
clipboard.read().unwrap_err(),
"System clipboard is not valid UTF-8"
);
}
#[test]
fn command_failures_and_stderr_cannot_leak_clipboard_text() {
let fixture = Fixture::new();
let failed = fixture.command(
"failed",
"printf 'private clipboard payload' >&2\nprintf 'private clipboard payload'\nexit 23",
);
let mut clipboard = SystemClipboard::with_commands(failed.clone(), failed);
let error = clipboard.read().unwrap_err();
assert!(error.contains("failed"));
assert!(!error.contains("private clipboard payload"));
let error = clipboard.write("private clipboard payload").unwrap_err();
assert!(!error.contains("private clipboard payload"));
let missing = fixture.0.join("not-an-executable");
let mut clipboard = SystemClipboard::with_commands(missing.clone(), missing);
assert!(clipboard.read().unwrap_err().contains("Cannot start"));
assert!(
clipboard
.write("secret")
.unwrap_err()
.contains("Cannot start")
);
}
#[test]
fn ignored_stderr_does_not_block_large_writes() {
let fixture = Fixture::new();
let mut clipboard = fixture.clipboard(b"");
let state = fixture.0.join("state");
let state = format!("'{}'", state.to_str().unwrap().replace('\'', "'\\''"));
clipboard
.commands
.as_mut()
.unwrap()
.as_mut()
.unwrap()
.writer
.as_mut()
.unwrap()
.program = fixture.command(
"noisy",
&format!("/bin/dd if=/dev/zero bs=65536 count=8 >&2\nexec /bin/cat > {state}"),
);
let text = "λ\n".repeat(300_000);
clipboard.write(&text).unwrap();
assert_eq!(clipboard.read().unwrap(), text);
}
#[test]
fn failed_process_cleanup_kills_and_reaps_a_running_child() {
use std::io::Read;
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
unsafe extern "C" {
fn waitpid(pid: i32, status: *mut i32, options: i32) -> i32;
}
let fixture = Fixture::new();
let hung = fixture.command("running", "printf R\nwhile :; do :; done");
let mut command = Command::new(hung);
command
.process_group(0)
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = command.spawn().unwrap();
let pid = child.id() as i32;
let mut ready = [0];
child
.stdout
.as_mut()
.unwrap()
.read_exact(&mut ready)
.unwrap();
assert_eq!(ready, [b'R']);
drop(ClipboardProcess(Some(child)));
let mut status = 0;
// SAFETY: waitpid's output pointer is valid; WNOHANG cannot block this check.
assert_eq!(unsafe { waitpid(pid, &mut status, 1) }, -1);
assert_eq!(std::io::Error::last_os_error().raw_os_error(), Some(10));
}
#[test]
fn immediate_injected_timeout_terminates_nonterminating_commands() {
let fixture = Fixture::new();
let hung = fixture.command("hung", "while :; do :; done");
let mut clipboard = SystemClipboard::with_commands(hung.clone(), hung);
// No assertion depends on wall-clock speed or a deadline remaining in the future.
clipboard.timeout = Duration::ZERO;
assert_eq!(
clipboard.read().unwrap_err(),
"System clipboard command timed out"
);
assert_eq!(
clipboard.write("text").unwrap_err(),
"System clipboard command timed out"
);
}
}
+1490 -2216
View File
File diff suppressed because it is too large Load Diff
+3227 -14021
View File
File diff suppressed because it is too large Load Diff
+1025
View File
File diff suppressed because it is too large Load Diff
+93 -948
View File
File diff suppressed because it is too large Load Diff
+1268
View File
File diff suppressed because it is too large Load Diff
+3 -428
View File
@@ -19,7 +19,7 @@ use std::{
thread::{self, JoinHandle}, thread::{self, JoinHandle},
}; };
const FILE_LIMIT: usize = 16 * 1024 * 1024; pub(crate) const FILE_LIMIT: usize = 16 * 1024 * 1024;
const BATCH_LIMIT: usize = 64 * 1024 * 1024; const BATCH_LIMIT: usize = 64 * 1024 * 1024;
const FILE_COUNT_LIMIT: usize = 1024; const FILE_COUNT_LIMIT: usize = 1024;
type Files = Vec<(usize, PathBuf, Arc<str>)>; type Files = Vec<(usize, PathBuf, Arc<str>)>;
@@ -456,430 +456,5 @@ fn changes(before: &str, after: &str) -> Vec<Option<Change>> {
} }
#[cfg(all(test, unix))] #[cfg(all(test, unix))]
mod tests { #[path = "../tests/unit/gutter.rs"]
use super::*; mod tests;
use std::ffi::OsString;
static NEXT_TEST: AtomicU64 = AtomicU64::new(0);
struct Temporary(PathBuf);
impl Temporary {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"vamp-gutter-test-{}-{}",
std::process::id(),
NEXT_TEST.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for Temporary {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn command(root: &Path, program: &str, args: &[&str]) -> String {
let mut command = Command::new(program);
if program == "jj" {
command.args([
"--config=user.name=\"Gutter Author\"",
"--config=user.email=\"gutter@example.test\"",
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
]);
}
let output = command
.args(args)
.current_dir(root)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
.env("JJ_CONFIG", "/dev/null")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().into()
}
fn git_fixture() -> (Temporary, PathBuf) {
let temp = Temporary::new();
command(&temp.0, "git", &["init", "--quiet", "--template="]);
command(&temp.0, "git", &["config", "user.name", "Gutter Author"]);
command(
&temp.0,
"git",
&["config", "user.email", "gutter@example.test"],
);
let path = temp.0.join("-literal [file]\n\t\".txt");
fs::write(&path, "first\nsecond\nthird\n").unwrap();
command(
&temp.0,
"git",
&["add", "--", path.file_name().unwrap().to_str().unwrap()],
);
command(&temp.0, "git", &["commit", "--quiet", "-m", "Baseline"]);
(temp, path)
}
#[test]
fn maps_replacements_surplus_deletions_empty_and_eof_rows() {
use Change::*;
for (before, after, expected) in [
("first\nsecond\n", "first\nsecond\n", vec![None, None, None]),
(
"first\r\nsecond\r\n",
"first\nsecond\n",
vec![None, None, None],
),
("first\rsecond\r", "first\nsecond\n", vec![None, None, None]),
(
"first\r\nsecond\r\nthird\r\n",
"first\nchanged\nthird\n",
vec![None, Some(Modified), None, None],
),
(
"first\nsecond\nthird\n",
"first\rchanged\rthird\r",
vec![None, Some(Modified), None, None],
),
("first\n", "first\nnew\n", vec![None, Some(Added), None]),
(
"old\n",
"changed\nsurplus\n",
vec![Some(Modified), Some(Added), None],
),
(
"old\nextra\nlast\n",
"changed\nlast\n",
vec![Some(Modified), None, None],
),
(
"first\nremoved\nlast\n",
"first\nlast\n",
vec![None, Some(Removed), None],
),
("first\nremoved\n", "first\n", vec![None, Some(Removed)]),
("first\nremoved", "first", vec![Some(Modified)]),
("first\nremoved", "first\n", vec![None, Some(Removed)]),
("removed\n", "", vec![Some(Removed)]),
("", "new\n", vec![Some(Added), None]),
("", "", vec![None]),
("same", "same\n", vec![Some(Modified), None]),
] {
assert_eq!(changes(before, after), expected, "{before:?} -> {after:?}");
}
}
#[test]
fn git_crlf_baseline_matches_normalized_editor_rows() {
let (temp, _) = git_fixture();
let path = temp.0.join("crlf");
fs::write(&path, "first\r\nsecond\r\nthird\r\n").unwrap();
command(&temp.0, "git", &["add", "--", "crlf"]);
command(
&temp.0,
"git",
&["commit", "--quiet", "-m", "CRLF baseline"],
);
assert_eq!(
read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(),
vec![None, None, None, None]
);
assert_eq!(
read_file(&path, "first\nchanged\nthird\n", &|| false).unwrap(),
vec![None, Some(Change::Modified), None, None]
);
}
#[test]
fn git_head_includes_staged_disk_and_unsaved_edits_without_writes() {
let (temp, path) = git_fixture();
fs::write(&path, "staged\nsecond\nthird\n").unwrap();
command(
&temp.0,
"git",
&["add", "--", path.file_name().unwrap().to_str().unwrap()],
);
fs::write(&path, "staged\ndisk\nthird\n").unwrap();
let index = fs::read(temp.0.join(".git/index")).unwrap();
let head = command(&temp.0, "git", &["rev-parse", "HEAD"]);
assert_eq!(
read_file(&path, "staged\ndisk\nunsaved\n", &|| false).unwrap(),
vec![
Some(Change::Modified),
Some(Change::Modified),
Some(Change::Modified),
None
]
);
assert_eq!(fs::read(temp.0.join(".git/index")).unwrap(), index);
assert_eq!(command(&temp.0, "git", &["rev-parse", "HEAD"]), head);
assert_eq!(fs::read_to_string(&path).unwrap(), "staged\ndisk\nthird\n");
fs::remove_file(&path).unwrap();
assert_eq!(
read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(),
vec![None, None, None, None]
);
assert_eq!(
read_file(&temp.0.join("new/named"), "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
let untracked = temp.0.join("untracked");
fs::write(&untracked, "unsaved\n").unwrap();
assert_eq!(
read_file(&untracked, "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
}
#[test]
fn unborn_untracked_missing_ignored_metadata_binary_and_symlink_paths() {
let temp = Temporary::new();
let path = temp.0.join("new/named");
assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty());
command(&temp.0, "git", &["init", "--quiet", "--template="]);
assert_eq!(
read_file(&path, "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
let disk = temp.0.join("untracked");
fs::write(&disk, "disk\n").unwrap();
assert_eq!(
read_file(&disk, "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
fs::write(temp.0.join(".gitignore"), "new/\nuntracked\n").unwrap();
assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty());
assert!(read_file(&disk, "unsaved\n", &|| false).unwrap().is_empty());
assert!(
read_file(&temp.0.join(".git/config"), "text\n", &|| false)
.unwrap()
.is_empty()
);
assert!(
read_file(&disk, "binary\0text", &|| false)
.unwrap()
.is_empty()
);
std::os::unix::fs::symlink(&disk, temp.0.join("link")).unwrap();
assert!(
read_file(&temp.0.join("link"), "text\n", &|| false)
.unwrap()
.is_empty()
);
std::os::unix::fs::symlink(&temp.0, temp.0.join("linked")).unwrap();
assert!(
read_file(&temp.0.join("linked/untracked"), "text\n", &|| false)
.unwrap()
.is_empty()
);
}
#[test]
fn git_non_utf8_filename_is_literal() {
use std::os::unix::ffi::OsStringExt;
let (temp, _) = git_fixture();
let name = OsString::from_vec(b"non-utf8-\xff".to_vec());
let path = temp.0.join(&name);
match fs::write(&path, "old\n") {
Ok(()) => {}
// APFS rejects non-UTF-8 names before repository code can read them.
Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => return,
Err(error) => panic!("Cannot create non-UTF-8 fixture: {error}"),
}
let mut add = git(&temp.0);
add.args(["add", "--"]).arg(&name);
checked(add, &|| false).unwrap();
command(&temp.0, "git", &["commit", "--quiet", "-m", "Byte path"]);
assert_eq!(
read_file(&path, "changed\n", &|| false).unwrap(),
vec![Some(Change::Modified), None]
);
}
#[test]
fn jj_parent_root_colocation_ignore_and_merge_queries_are_read_only() {
for colocate in ["--colocate", "--no-colocate"] {
let temp = Temporary::new();
command(&temp.0, "jj", &["git", "init", colocate, "."]);
let name = "-literal [file]\n\t\".txt";
let path = temp.0.join(name);
fs::write(&path, "original\n").unwrap();
assert_eq!(
read_file(&path, "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
fs::write(temp.0.join(".gitignore"), "ignored\n").unwrap();
assert!(
read_file(&temp.0.join("ignored"), "text\n", &|| false)
.unwrap()
.is_empty()
);
command(&temp.0, "jj", &["describe", "-m", "Baseline"]);
command(&temp.0, "jj", &["new"]);
let operation = command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id",
],
);
let store = store_for_jj(&temp.0).unwrap();
let refs = fs::read(store.join("HEAD")).unwrap();
fs::write(&path, "disk edit\n").unwrap();
assert_eq!(
read_file(&path, "inserted\noriginal\n", &|| false).unwrap(),
vec![Some(Change::Added), None, None]
);
assert_eq!(fs::read(store.join("HEAD")).unwrap(), refs);
assert_eq!(
command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id",
]
),
operation
);
let parent = command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"log",
"--no-graph",
"-r",
"@-",
"-T",
"commit_id",
],
);
command(&temp.0, "jj", &["new", "root()"]);
fs::write(temp.0.join("sibling"), "sibling\n").unwrap();
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
command(&temp.0, "jj", &["new", &parent, "@"]);
assert!(
read_file(&path, "original\n", &|| false)
.unwrap()
.is_empty()
);
}
}
#[test]
fn worker_retains_only_latest_batch_and_file_errors_keep_good_siblings() {
let (_temp, path) = git_fixture();
let mut worker = Worker::start().unwrap();
let oversized: Arc<str> = "x".repeat(FILE_LIMIT + 1).into();
for id in 0..20 {
worker
.request(
id,
vec![
(5, path.clone(), "first\nchanged\nthird\n".into()),
(8, path.clone(), Arc::clone(&oversized)),
],
None,
)
.unwrap();
}
loop {
if let Some((id, result, picker)) = worker.poll() {
assert!(picker.is_none());
assert_eq!(id, 19);
assert_eq!(result[0].0, 5);
assert_eq!(
result[0].1.as_ref().unwrap(),
&vec![None, Some(Change::Modified), None, None]
);
assert_eq!(result[1].0, 8);
assert_eq!(
result[1].1.as_ref().unwrap_err(),
"Gutter file exceeds 16 MiB"
);
break;
}
thread::yield_now();
}
assert_eq!(
worker
.request(
20,
vec![(0, path, "x".repeat(BATCH_LIMIT + 1).into())],
None
)
.unwrap_err()
.kind(),
io::ErrorKind::InvalidInput
);
}
#[test]
fn picker_status_is_optional_and_errors_do_not_discard_line_results() {
let (temp, path) = git_fixture();
let mut worker = Worker::start().unwrap();
worker
.request(
1,
vec![],
Some((
temp.0.clone(),
vec![(path.clone(), "dirty buffer\n".into())],
)),
)
.unwrap();
loop {
if let Some((id, lines, picker)) = worker.poll() {
assert_eq!(id, 1);
assert!(lines.is_empty());
let picker = picker.unwrap();
assert_eq!(picker.root, temp.0);
let changes = picker.result.unwrap();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].path, path.strip_prefix(&temp.0).unwrap());
assert_eq!(changes[0].status, crate::diff::FileStatus::Modified);
break;
}
thread::yield_now();
}
worker
.request(
2,
vec![(4, path.clone(), "first\nchanged\nthird\n".into())],
Some((
temp.0.clone(),
vec![(path, "x".repeat(FILE_LIMIT + 1).into())],
)),
)
.unwrap();
loop {
if let Some((id, lines, picker)) = worker.poll() {
assert_eq!(id, 2);
assert_eq!(lines[0].0, 4);
assert!(lines[0].1.is_ok());
assert!(picker.unwrap().result.unwrap_err().contains("16 MiB"));
break;
}
thread::yield_now();
}
}
}
+6
View File
@@ -5,15 +5,21 @@ pub mod clipboard;
pub mod diff; pub mod diff;
pub mod editor; pub mod editor;
pub mod files; pub mod files;
mod forge;
pub mod gutter; pub mod gutter;
pub mod lsp; pub mod lsp;
mod lsp_config; 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 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;
+7 -248
View File
@@ -238,8 +238,11 @@ impl Lsp {
if !Arc::ptr_eq(&cached.snapshot, &snapshot) { if !Arc::ptr_eq(&cached.snapshot, &snapshot) {
cached.snapshot = Arc::clone(&snapshot); cached.snapshot = Arc::clone(&snapshot);
cached.diagnostics.clear(); cached.diagnostics.clear();
cached.semantic_tokens = None; if cached.semantic_tokens.take().is_some() {
self.changed_semantics.insert(snapshot.id); // Discard queued old coordinates, not explicit clears such as retirement.
// The editor keeps remapped colors until a server reply replaces them.
self.changed_semantics.remove(&snapshot.id);
}
self.changed_diagnostics.insert(snapshot.id); self.changed_diagnostics.insert(snapshot.id);
} }
} }
@@ -2990,249 +2993,5 @@ fn uri_path(uri: &str) -> Result<PathBuf, String> {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "../tests/unit/lsp.rs"]
use super::*; mod tests;
#[test]
fn native_definitions_preserve_snapshot_identity_and_utf16_until_navigation() {
let source = Source {
buffer_id: 7,
revision: 3,
row: 0,
column: 0,
request_id: 11,
};
let path = Path::new("/vamp-immutable-snapshot/Before/renamed/λ.rs");
let span = json!({
"start": {"line": 4, "character": 5},
"end": {"line": 4, "character": 8}
});
for location in [
json!({"uri": file_uri(path).unwrap(), "range": span}),
json!({"targetUri": file_uri(path).unwrap(),
"targetRange": span, "targetSelectionRange": span}),
] {
let effects = Runtime::definition_effects(&source, &location).unwrap();
let expected_target_range = location.get("targetUri").map(|_| Utf16Range {
start: (4, 5),
end: (4, 8),
});
assert!(matches!(
effects.as_slice(),
[UpdateEffect::NavigateUtf16 {
buffer_id: 7, revision: 3, path: target,
range: Utf16Range { start: (4, 5), end: (4, 8) }, target_range,
}] if target == path && *target_range == expected_target_range
));
}
for invalid in [
json!({"start": {"line": 4, "character": 8},
"end": {"line": 4, "character": 5}}),
json!({"start": {"line": -1, "character": 0},
"end": {"line": 0, "character": 0}}),
] {
for location in [
json!({"uri": file_uri(path).unwrap(), "range": invalid}),
json!({"targetUri": file_uri(path).unwrap(),
"targetRange": invalid, "targetSelectionRange": span}),
json!({"targetUri": file_uri(path).unwrap(),
"targetRange": span, "targetSelectionRange": invalid}),
] {
assert!(Runtime::definition_effects(&source, &location).is_err());
}
}
assert!(
serde_json::from_value::<UpdateEffect>(json!({
"type": "navigate_utf16", "buffer_id": 7, "revision": 3,
"path": path, "range": {"start": [4,5], "end": [4,8]},
"target_range": null
}))
.is_err()
);
}
#[test]
fn semantic_decoder_validates_raw_coordinates_before_grapheme_rounding() {
let options = semantic_options(Some(&json!({
"full": true,
"legend": {"tokenTypes": ["custom", "function"],
"tokenModifiers": ["custom", "readonly", "declaration", "async"]}
})))
.unwrap()
.unwrap();
let tokens = decode_semantic_tokens(
"A😀e\u{301}Z\r\n\r\nx",
&json!({"data": [
0, 1, 2, 1, 2,
0, 2, 1, 1, 12,
0, 1, 1, 1, 0,
0, 1, 999, 0, 0,
1, 0, 1, 1, 0,
1, 0, 0, 1, 0,
0, 0, 100, 1, 0
]}),
&options,
)
.unwrap()
.unwrap();
assert_eq!(tokens.len(), 4);
assert_eq!(tokens[0].start, TextPosition { row: 0, column: 1 });
assert_eq!(tokens[0].end, TextPosition { row: 0, column: 2 });
assert_eq!(tokens[0].modifiers, SemanticTokenModifier::Readonly as u16);
assert_eq!(tokens[1].start, TextPosition { row: 0, column: 2 });
assert_eq!(tokens[1].end, TextPosition { row: 0, column: 3 });
assert_eq!(
tokens[1].modifiers,
SemanticTokenModifier::Declaration as u16 | SemanticTokenModifier::Async as u16
);
assert_eq!(tokens[2].start, tokens[1].start);
assert_eq!(tokens[3].end, TextPosition { row: 2, column: 1 });
for data in [
json!([0, 0, 1, 1]),
json!([-1, 0, 1, 1, 0]),
json!([0, 0.5, 1, 1, 0]),
json!([0, 0, 1, 1, 2147483648u64]),
json!([0, 0, 1, 65536, 0]),
json!([0, 0, 1, 2, 0]),
json!([0, 0, 1, 1, 16]),
json!([0, 2, 1, 0, 0]),
json!([0, 1, 1, 1, 0]),
json!([0, 7, 1, 1, 0]),
json!([2, 0, 1, 1, 0]),
json!([0, 1, 2, 1, 0, 0, 1, 1, 1, 0]),
] {
assert!(
decode_semantic_tokens("A😀e\u{301}Z\n", &json!({"data": data}), &options).is_err(),
"{data}"
);
}
let count = MAX_BODY / std::mem::size_of::<SemanticToken>() + 1;
assert!(
decode_semantic_tokens("x", &json!({"data": vec![0; count * 5]}), &options).is_err()
);
}
#[test]
fn unicode_positions_and_local_uri_boundaries() {
let text = "A😀e\u{301}Z\n";
let (start, end) = range(
text,
&json!({"start": {"line": 0, "character": 2}, "end": {"line": 0, "character": 4}}),
)
.unwrap();
assert_eq!(start, TextPosition { row: 0, column: 1 });
assert_eq!(end, TextPosition { row: 0, column: 3 });
assert_eq!(
outbound_position(text, 0, 3).unwrap(),
json!({"line": 0, "character": 5})
);
assert_eq!(
position(text, &json!({"line": 1, "character": 999}), false).unwrap(),
TextPosition { row: 1, column: 0 }
);
assert!(
range(
text,
&json!({"start": {"line": 0, "character": 3}, "end": {"line": 0, "character": 2}})
)
.is_err()
);
let path = Path::new("/tmp/source space λ😀.rs");
assert_eq!(uri_path(&file_uri(path).unwrap()).unwrap(), path);
assert_eq!(
uri_path("file://localhost/tmp/a%23b").unwrap(),
Path::new("/tmp/a#b")
);
for uri in [
"https://example.invalid/a",
"file://remote/tmp/a",
"file:///tmp/%",
"file:///tmp/%00",
"file:///tmp/%FF",
"file:///tmp/a?query",
] {
assert!(uri_path(uri).is_err(), "{uri}");
}
}
#[test]
fn hover_contents_keep_lines_and_reject_malformed_values() {
let source = "A😀e\u{301}Z\r\n";
for kind in ["plaintext", "markdown"] {
assert_eq!(
hover_text(
&json!({
"contents": {"kind": kind, "value": "😀e\u{301}\r\n\tcode\rnext\u{1b}\u{85}"},
"range": {"start": {"line": 0, "character": 2},
"end": {"line": 0, "character": 4}}
}),
source,
)
.unwrap(),
Some("😀e\u{301}\n code\nnext ".into()),
);
}
for contents in [
json!("line\ncode"),
json!({"language": "rust", "value": "line\ncode"}),
] {
assert_eq!(
hover_text(&json!({"contents": contents}), source).unwrap(),
Some("line\ncode".into()),
);
}
assert_eq!(
hover_text(
&json!({"contents": ["first", {"language": "rust", "value": "fn main() {\n}"}]}),
source,
)
.unwrap(),
Some("first\n\nfn main() {\n}".into()),
);
for value in [
Value::Null,
json!({"contents": ""}),
json!({"contents": []}),
] {
assert_eq!(hover_text(&value, source).unwrap(), None);
}
for value in [
json!({}),
json!({"contents": {"kind": "html", "value": "x"}}),
json!({"contents": {"language": "rust", "value": false}}),
json!({"contents": [{"kind": "markdown", "value": "x"}]}),
json!({"contents": "x", "range": {"start": {"line": 1, "character": 0},
"end": {"line": 0, "character": 0}}}),
json!({"contents": "\t".repeat(MAX_BODY / 4 + 1)}),
] {
assert!(hover_text(&value, source).is_err());
}
}
#[test]
fn completion_text_limit_counts_the_atomic_batch_not_sort_metadata() {
let source = Source {
buffer_id: 1,
revision: 0,
row: 0,
column: 0,
request_id: 1,
};
let at_limit = json!([{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)}]);
assert_eq!(
decode_completion("", &at_limit, &source, None).unwrap()[0].edits[0]
.text
.len(),
MAX_BODY - 1
);
let over_limit = json!([
{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)},
{"label": "y"}
]);
assert!(
decode_completion("", &over_limit, &source, None)
.unwrap_err()
.contains("4 MiB")
);
}
}
+4 -3
View File
@@ -11,9 +11,10 @@ pub fn resolve(value: Value) -> Result<Vec<ServerConfig>, String> {
return Err("LSP language keys must not be empty or contain control characters".into()); return Err("LSP language keys must not be empty or contain control characters".into());
} }
let preset: Option<(&[&[&str]], &str)> = match language_id.as_str() { let preset: Option<(&[&[&str]], &str)> = match language_id.as_str() {
"rust" => Some((&[&["rust-analyzer"]], "rs")), crate::syntax::RUST_ID => Some((&[&["rust-analyzer"]], "rs")),
"python" => Some((&[&["ty", "server"], &["ruff", "server"]], "py")), crate::syntax::PYTHON_ID => Some((&[&["ty", "server"], &["ruff", "server"]], "py")),
"ruby" => 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 {
+77 -16
View File
@@ -2,12 +2,21 @@ use std::{env, io, path::PathBuf};
use vamp::{session::EditorSession, tui}; use vamp::{session::EditorSession, tui};
mod review_cli;
// Only the first argument can be this process-to-process terminal handoff marker. // Only the first argument can be this process-to-process terminal handoff marker.
const INHERITED_SCREEN: &str = "--vamp-inherit-alternate-screen"; const INHERITED_SCREEN: &str = "--vamp-inherit-alternate-screen";
const HELP: &str = "vamp — modal editor with isolated Ruby plugins 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 show ID [--repo PATH] [--json] [--all]
vamp [--config PATH | --no-config] --review delete REVIEW_ID COMMENT_ID [--repo PATH] [--json]
Review CLI: list/show are read-only; delete commits one comment deletion immediately.
Show defaults to unresolved comments; --all includes resolved.
Use vamp -- --review to edit a file named --review.
Default keys (configurable with config.bind): Default keys (configurable with config.bind):
All modes: Ctrl+p command palette; type to filter, Ctrl+j/k or arrows select, Enter accept, Esc cancel All modes: Ctrl+p command palette; type to filter, Ctrl+j/k or arrows select, Enter accept, Esc cancel
@@ -18,26 +27,28 @@ Files: Space o floating tree INSERT; Space e docked tree NORMAL (right by defaul
i filters; Esc retains filter and returns NORMAL, then editor; Ctrl+u clears in either mode i filters; Esc retains filter and returns NORMAL, then editor; Ctrl+u clears in either mode
Ctrl+t view, Ctrl+w close, Ctrl+r refresh Ctrl+t view, Ctrl+w close, Ctrl+r refresh
Normal: i/a insert, I/A insert at first nonblank/end, o/O new line Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
h/j/k/l move, 0/$ line ends, x delete, J join lines h/j/k/l move, 0/$ line ends, x/Delete cut hovered graphemes, J join lines
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
Normal/Visual: f<char>/F<char> seek next/previous on current line; 2fx seeks second x Normal/Visual: f<char>/F<char> seek next/previous on current line; 2fx seeks second x
gf follows Markdown file links/reference links and heading fragments (no LSP) gf follows Markdown file links/reference links and heading fragments (no LSP)
Normal/Visual: gt first line, gb last line, at first nonblank; Esc cancels g Normal/Visual: gt first line, gb last line, at first nonblank; Esc cancels g
Normal/Visual: <line>g jumps to that one-based source line at first nonblank, clamped at EOF Normal/Visual: <line>g jumps to that one-based source line at first nonblank, clamped at EOF
Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported) Ctrl+o older jump location; Ctrl+i newer (Tab too outside comparisons; counts supported)
_ splits above/below, | left/right; Option/Alt+h/j/k/l focuses neighboring panes _ splits above/below, | left/right; Option/Alt+h/j/k/l focuses neighboring panes
Ctrl+Option/Alt+h/l previous/next tab; :tab.new opens current buffer in another tab Ctrl+Option/Alt+h/l previous/next tab; :tab.new opens current buffer in another tab
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
u undo, U redo, : command, / search, n/N next/previous match Normal/Visual: Ctrl+u/d scroll up/down without moving carets; configurable distance (default half viewport), counts supported
u undo, U redo, : command, / search, * search word, n/N next/previous match
yy copies whole lines; dd deletes them; cc changes them and enters Insert; counts: 3yy, 2dd, 2cc yy copies whole lines; dd deletes them; cc changes them and enters Insert; counts: 3yy, 2dd, 2cc
dw/db cut forward/backward words on the current line; cw/cb cut the same spans and enter Insert dw/db cut forward/backward words on the current line; cw/cb cut the same spans and enter Insert
Word counts before/after the operator multiply: d2w, 2cw, 2c3b; newlines are preserved Word counts before/after the operator multiply: d2w, 2cw, 2c3b; newlines are preserved
Linewise p/P paste below/above the current row; characterwise p/P paste after/before the grapheme Linewise p/P paste below/above the current row; characterwise p/P paste after/before the grapheme
Normal/Visual: . repeats last native edit at current targets (one undo group) Normal/Visual: . repeats last native edit at current targets (one undo group)
v characterwise select, V linewise select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below v characterwise select, V linewise select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below
y/d/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it y/d/x/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it
Esc keeps only the primary caret Esc keeps only the primary caret
Visual: motions extend selections, y copy, d/x/Delete cut, c change Visual: motions extend selections, y copy, d/x/Delete cut, c change, p/P replace selections
: opens command input with the selection preserved; Esc returns to that selection
r<char> replaces every selected grapheme, preserving newlines; one undo action r<char> replaces every selected grapheme, preserving newlines; one undo action
I/A insert at each selected line's first nonblank/end I/A insert at each selected line's first nonblank/end
Ctrl+l split rows, Ctrl+n add occurrence, Ctrl+s skip, Ctrl+a all Ctrl+l split rows, Ctrl+n add occurrence, Ctrl+s skip, Ctrl+a all
@@ -45,11 +56,15 @@ Visual: motions extend selections, y copy, d/x/Delete cut, c change
Insert: Ctrl+n requests LSP completion at primary caret, then cycles next; Ctrl+j/k next/previous Insert: Ctrl+n requests LSP completion at primary caret, then cycles next; Ctrl+j/k next/previous
Candidates replace text live; Original text restores it; other keys close and act normally Candidates replace text live; Original text restores it; other keys close and act normally
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 tab; Esc keeps text and 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
Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps) 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)
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
@@ -81,12 +96,36 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
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
Selected comments highlight their precise text
:diff.review.edit/delete [ID], :diff.review.next/previous, :diff.review.overview
:diff.review.summary; :diff.review.verdict looks_good|changes_requested|none
:diff.review.copy copies all saved comments in CLI format using the configured clipboard
:w saves comment/summary text, including hidden drafts; verdicts/deletions commit immediately
:diff.review.save PATH exports JSON; :diff.review.open [ID|PATH] selects/imports; :diff.review.new
Normal Esc or :pane.close discards unsaved text and dismisses the floating editor
Snapshot anchors stay immutable; stale comments lose gutter markers; no publishing
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/Drafts/All tabs, type to search
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
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)
:resolve.next/previous navigates differing hunks; :resolve.use_source [N] takes a hunk
:resolve.source N selects Alternative; :resolve.base N selects History base
:resolve.draft returns to Draft; :resolve.view.toggle switches Resolution/History
:w saves and checks jj; :resolve.files toggles the explorer; :resolve.close closes cleanly
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
Set VAMP_RUBY to choose the Ruby executable. 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
@@ -96,20 +135,33 @@ Hover binding: config.bind \"K\", config.core.lsp.hover (no default key)";
struct Options { struct Options {
file: Option<PathBuf>, file: Option<PathBuf>,
config: Option<PathBuf>, config: Option<PathBuf>,
review: Option<review_cli::Request>,
} }
fn options(inherited_screen: bool) -> Result<Option<Options>, String> { fn options(inherited_screen: bool) -> Result<Option<Options>, String> {
let mut args = env::args_os().skip(1 + usize::from(inherited_screen)); parse_options(env::args_os().skip(1 + usize::from(inherited_screen)))
}
fn parse_options(
args: impl IntoIterator<Item = std::ffi::OsString>,
) -> Result<Option<Options>, String> {
let mut args = args.into_iter();
let mut file = None; let mut file = None;
let mut config = None; let mut config = None;
let mut config_selected = false; let mut config_selected = false;
let mut positional = false; let mut positional = false;
let mut review = None;
while let Some(arg) = args.next() { while let Some(arg) = args.next() {
if !positional && (arg == "--help" || arg == "-h") { if !positional && (arg == "--help" || arg == "-h") {
println!("{HELP}"); println!("{HELP}");
return Ok(None); return Ok(None);
} else if !positional && arg == "--" { } else if !positional && arg == "--" {
if review.is_some() {
return Err("unexpected review argument: --".into());
}
positional = true; positional = true;
} else if !positional && arg == "--review" && file.is_none() && review.is_none() {
review = Some(review_cli::Request::parse(&mut args)?);
} else if !positional && arg == "--config" { } else if !positional && arg == "--config" {
if config_selected { if config_selected {
return Err("use only one of --config and --no-config".into()); return Err("use only one of --config and --no-config".into());
@@ -123,6 +175,8 @@ fn options(inherited_screen: bool) -> Result<Option<Options>, String> {
return Err("use only one of --config and --no-config".into()); return Err("use only one of --config and --no-config".into());
} }
config_selected = true; config_selected = true;
} else if !positional && review.is_some() {
review.as_mut().unwrap().option(&arg, &mut args)?;
} else if !positional && arg.to_string_lossy().starts_with('-') { } else if !positional && arg.to_string_lossy().starts_with('-') {
return Err(format!("unknown option: {}", arg.to_string_lossy())); return Err(format!("unknown option: {}", arg.to_string_lossy()));
} else if file.replace(PathBuf::from(arg)).is_some() { } else if file.replace(PathBuf::from(arg)).is_some() {
@@ -137,24 +191,27 @@ fn options(inherited_screen: bool) -> Result<Option<Options>, String> {
.map(|home| home.join("vamp/config.rb")) .map(|home| home.join("vamp/config.rb"))
.filter(|path| path.exists()); .filter(|path| path.exists());
} }
Ok(Some(Options { file, config })) Ok(Some(Options {
file,
config,
review,
}))
} }
fn run() -> Result<(), String> { fn run() -> Result<(), String> {
let inherited_screen = env::args_os() let inherited_screen = env::args_os()
.nth(1) .nth(1)
.is_some_and(|arg| arg == INHERITED_SCREEN); .is_some_and(|arg| arg == INHERITED_SCREEN);
if inherited_screen { let options = options(inherited_screen)?;
return tui::TerminalSession::run(true, || { if let Some(options) = &options {
run_editor(options(true).map_err(io::Error::other)?, true) if let Some(request) = &options.review {
}) return review_cli::run(request, options.config.as_deref());
.map_err(|error| error.to_string()); }
} }
let options = options(false)?;
if options.is_none() { if options.is_none() {
return Ok(()); return Ok(());
} }
tui::TerminalSession::run(false, || run_editor(options, false)) tui::TerminalSession::run(inherited_screen, || run_editor(options, inherited_screen))
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
} }
@@ -208,3 +265,7 @@ fn main() {
std::process::exit(1); std::process::exit(1);
} }
} }
#[cfg(test)]
#[path = "../tests/unit/cli_args.rs"]
mod tests;
+2 -145
View File
@@ -560,148 +560,5 @@ fn slug(title: &str) -> String {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "../tests/unit/markdown.rs"]
use super::*; mod tests;
fn at(text: &str, needle: &str) -> Result<LinkTarget, String> {
let byte = text.find(needle).unwrap();
let prefix = &text[..byte];
let row = prefix.bytes().filter(|byte| *byte == b'\n').count();
let column = prefix.rsplit('\n').next().unwrap().graphemes(true).count();
link_at(text, TextPosition { row, column })
}
fn target(path: Option<&str>, fragment: Option<&str>) -> LinkTarget {
LinkTarget {
path: path.map(PathBuf::from),
fragment: fragment.map(str::to_owned),
}
}
#[test]
fn follows_inline_labels_destinations_and_grapheme_columns() {
let text = "👩‍💻 [go **there**](<../My%20Notes/%E6%97%A5%E6%9C%AC\\(1\\).md#caf%C3%A9>)";
let expected = target(Some("../My Notes/日本(1).md"), Some("café"));
assert_eq!(at(text, "there").unwrap(), expected);
assert_eq!(at(text, "My%20").unwrap(), expected);
assert_eq!(
at("[absolute](/Tmp/Note.md)", "absolute").unwrap(),
target(Some("/Tmp/Note.md"), None)
);
assert_eq!(
at("[here](#title)", "here").unwrap(),
target(None, Some("title"))
);
assert_eq!(at("[top]()", "top").unwrap(), target(None, None));
assert_eq!(
at("[here](#title)", "[here").unwrap(),
target(None, Some("title"))
);
assert_eq!(
at("[file](literal%23name.md)", "file").unwrap(),
target(Some("literal#name.md"), None)
);
assert_eq!(
at("[file](My\\[Notes\\].md)", "file").unwrap(),
target(Some("My[Notes].md"), None)
);
}
#[test]
fn resolves_all_reference_forms_and_multiline_block_definitions() {
let text = "[full][ A B ] [a b][] [A B]\n\n> [a b]:\n> <My%20Notes.md#title>\n> \"A title\"\n\n[a b]: ignored.md\n";
let expected = target(Some("My Notes.md"), Some("title"));
for needle in ["full", " A B ", "a b][]", "A B]"] {
assert_eq!(at(text, needle).unwrap(), expected);
}
assert_eq!(
at("[Straße]\n\n[STRASSE]: file.md", "Straße").unwrap(),
target(Some("file.md"), None)
);
let text = "> [multi\n> line][target]\n\n[target]: other.md\n";
assert_eq!(at(text, "line]").unwrap(), target(Some("other.md"), None));
}
#[test]
fn rejects_images_code_plaintext_and_external_urls() {
for (text, needle) in [
("![image](file.md)", "image"),
("`[code](file.md)`", "code"),
("```md\n[code](file.md)\n```", "file"),
(" [code](file.md)", "code"),
("plain file.md text", "file"),
("[web](https://example.com)", "web"),
("[web](//example.com/a)", "web"),
("[web](mailto:a@example.com)", "web"),
("<https://example.com>", "example"),
] {
assert!(at(text, needle).is_err(), "{text}");
}
assert!(
at("[missing]", "missing")
.unwrap_err()
.contains("not defined")
);
assert!(
at("[bad](file%ZZ.md)", "bad")
.unwrap_err()
.contains("percent")
);
assert!(
at("[bad](file%FF.md)", "bad")
.unwrap_err()
.contains("UTF-8")
);
assert!(at("[bad](#)", "bad").unwrap_err().contains("empty"));
}
#[test]
fn locates_visible_unicode_headings_setext_and_unique_slugs() {
let text = "# **Café** [日本](destination.md) `code` ###\n\n> ## Café 日本 code\n\nCafé 日本 code\n====\n\n# Café 日本 code-1\n\n# Cafe\u{301} _name_ ![alt](hidden.md)\n\n```md\n# Hidden\n```\n";
assert_eq!(heading_row(text, "café-日本-code").unwrap(), 0);
assert_eq!(heading_row(text, "café-日本-code-1").unwrap(), 2);
assert_eq!(heading_row(text, "café-日本-code-2").unwrap(), 4);
assert_eq!(heading_row(text, "café-日本-code-1-1").unwrap(), 7);
assert_eq!(heading_row(text, "cafe\u{301}-name-alt").unwrap(), 9);
assert!(heading_row(text, "hidden").is_err());
assert!(heading_row(text, "destinationmd").is_err());
assert!(heading_row(text, "").is_err());
assert_eq!(
heading_row("# [Heading][ref]\n\n[ref]: ignored.md", "heading").unwrap(),
0
);
assert_eq!(
heading_row("# ![Alt][ref]\n\n[ref]: ignored.md", "alt").unwrap(),
0
);
assert_eq!(heading_row("# ![Alt][missing]", "altmissing").unwrap(), 0);
}
#[test]
fn renders_entities_or_reports_the_explicit_entity_ceiling() {
assert_eq!(
at("[file](a&amp;b&#x20;c.md)", "file").unwrap(),
target(Some("a&b c.md"), None)
);
assert_eq!(
at("[file](&lt;name&gt;)", "file").unwrap(),
target(Some("<name>"), None)
);
assert!(
at("[file](a&copy;b.md)", "file")
.unwrap_err()
.contains("Unsupported Markdown HTML entity")
);
assert_eq!(heading_row("# A &amp; B &#xE9;\n", "a--b-é").unwrap(), 0);
assert!(
heading_row("# &copy;\n", "copy")
.unwrap_err()
.contains("Unsupported Markdown HTML entity")
);
assert_eq!(heading_row("# &copy;\n# Valid\n", "valid").unwrap(), 1);
assert_eq!(
heading_row("> First\n> Second\n> ------\n", "first-second").unwrap(),
0
);
}
}
+2 -125
View File
@@ -248,128 +248,5 @@ pub fn directional_neighbor(
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "../tests/unit/panes.rs"]
use super::*; mod tests;
fn rect(x: u16, y: u16, width: u16, height: u16) -> PaneRect {
PaneRect {
x,
y,
width,
height,
}
}
fn leaves(tree: &PaneTree, bounds: PaneRect) -> Vec<(usize, PaneRect)> {
let mut leaves = Vec::new();
tree.visit_rects(bounds, |id, rect| leaves.push((id, rect)));
leaves
}
#[test]
fn nested_layout_partitions_bounds_and_preserves_ids() {
let bounds = rect(3, 5, 11, 9);
let mut tree = PaneTree::new(10);
assert!(tree.split(10, 20, SplitAxis::Vertical));
assert!(tree.split(20, 30, SplitAxis::Horizontal));
assert!(!tree.split(99, 40, SplitAxis::Horizontal));
assert!(!tree.split(10, 30, SplitAxis::Horizontal));
assert!(!tree.split(10, 10, SplitAxis::Horizontal));
assert_eq!(tree.len(), 3);
let panes = leaves(&tree, bounds);
assert_eq!(
panes,
vec![
(10, rect(3, 5, 5, 9)),
(20, rect(8, 5, 6, 4)),
(30, rect(8, 9, 6, 5))
]
);
let mut cells = std::collections::HashSet::new();
for (_, pane) in panes {
for y in pane.y..pane.y + pane.height {
for x in pane.x..pane.x + pane.width {
assert!(cells.insert((x, y)), "overlapping pane cell");
}
}
}
assert_eq!(
cells.len(),
usize::from(bounds.width) * usize::from(bounds.height)
);
assert!(leaves(&tree, rect(0, 0, 0, 10)).is_empty());
assert!(leaves(&tree, rect(0, 0, 10, 0)).is_empty());
}
#[test]
fn closing_expands_nested_sibling_and_refuses_last() {
let bounds = rect(0, 0, 20, 12);
let mut tree = PaneTree::new(1);
tree.split(1, 2, SplitAxis::Vertical);
tree.split(2, 3, SplitAxis::Horizontal);
assert_eq!(tree.close(1), Some(2));
assert_eq!(
leaves(&tree, bounds),
vec![(2, rect(0, 0, 20, 6)), (3, rect(0, 6, 20, 6))]
);
assert_eq!(tree.close(99), None);
assert_eq!(tree.close(2), Some(3));
assert_eq!(leaves(&tree, bounds), vec![(3, bounds)]);
assert_eq!(tree.close(3), None);
assert_eq!(tree.len(), 1);
assert!(!tree.contains(1));
assert!(tree.contains(3));
}
#[test]
fn directional_focus_handles_unequal_nested_regions() {
let bounds = rect(0, 0, 20, 12);
let mut tree = PaneTree::new(1);
tree.split(1, 2, SplitAxis::Vertical);
tree.split(2, 3, SplitAxis::Horizontal);
tree.split(3, 4, SplitAxis::Horizontal);
assert_eq!(tree.neighbor(1, PaneDirection::Right, bounds), Some(3));
assert_eq!(tree.neighbor(2, PaneDirection::Down, bounds), Some(3));
assert_eq!(tree.neighbor(3, PaneDirection::Down, bounds), Some(4));
assert_eq!(tree.neighbor(4, PaneDirection::Up, bounds), Some(3));
assert_eq!(tree.neighbor(4, PaneDirection::Left, bounds), Some(1));
assert_eq!(tree.neighbor(1, PaneDirection::Left, bounds), None);
assert_eq!(tree.neighbor(99, PaneDirection::Right, bounds), None);
assert_eq!(
tree.neighbor(1, PaneDirection::Right, rect(0, 0, 0, 0)),
None
);
}
#[test]
fn navigation_prefers_overlap_then_edge_and_breaks_ties_by_id() {
let current = rect(10, 10, 10, 10);
let candidates = [
(1, rect(20, 0, 5, 5)),
(2, rect(25, 10, 5, 10)),
(4, rect(22, 10, 5, 10)),
(3, rect(22, 10, 5, 10)),
(0, rect(20, 10, 0, 10)),
];
assert_eq!(
directional_neighbor(current, PaneDirection::Right, candidates.into_iter()),
Some(3)
);
assert_eq!(
directional_neighbor(current, PaneDirection::Right, candidates.into_iter().rev()),
Some(3)
);
assert_eq!(
directional_neighbor(
current,
PaneDirection::Up,
[(7, rect(10, 0, 10, 5))].into_iter()
),
Some(7)
);
assert_eq!(
directional_neighbor(current, PaneDirection::Left, candidates.into_iter()),
None
);
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ pub enum DiagnosticSeverity {
} }
/// Zero-based extended-grapheme positions, independent of any server encoding. /// Zero-based extended-grapheme positions, independent of any server encoding.
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd)] #[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, Ord, PartialEq, PartialOrd)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct TextPosition { pub struct TextPosition {
pub row: usize, pub row: usize,
+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;
}
}
}
+864 -695
View File
File diff suppressed because it is too large Load Diff
+1058
View File
File diff suppressed because it is too large Load Diff
+1033
View File
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
use serde_json::{Value, json};
use std::{
ffi::{OsStr, OsString},
path::{Path, PathBuf},
};
use vamp::{
repository,
review::{Review, Store},
ruby,
};
pub struct Request {
id: Option<i64>,
delete_comment: Option<u64>,
repo: Option<PathBuf>,
json: bool,
all: bool,
}
impl Request {
pub fn parse(args: &mut impl Iterator<Item = OsString>) -> Result<Self, String> {
let usage = "--review requires list, show ID, or delete REVIEW_ID COMMENT_ID";
let command = args.next().ok_or(usage)?;
let id = if command == "list" {
None
} else if command == "show" || command == "delete" {
let error = if command == "show" {
"`show` requires a positive review ID.\nUsage: vamp --review show ID [--all]\n`--all` includes resolved comments; it does not select all reviews."
} else {
"`delete` requires a positive review ID.\nUsage: vamp --review delete REVIEW_ID COMMENT_ID"
};
let id = args.next().ok_or(error)?;
Some(
id.to_str()
.and_then(|id| id.parse::<i64>().ok())
.filter(|id| *id > 0)
.ok_or(error)?,
)
} else {
return Err(usage.into());
};
let delete_comment = if command == "delete" {
let error = "delete requires a positive comment ID";
let id = args.next().ok_or(error)?;
Some(
id.to_str()
.and_then(|id| id.parse::<u64>().ok())
.filter(|id| *id > 0)
.ok_or(error)?,
)
} else {
None
};
Ok(Self {
id,
delete_comment,
repo: None,
json: false,
all: false,
})
}
pub fn option(
&mut self,
arg: &OsStr,
args: &mut impl Iterator<Item = OsString>,
) -> Result<(), String> {
if arg == "--repo" && self.repo.is_none() {
self.repo = Some(PathBuf::from(args.next().ok_or("--repo requires a path")?));
} else if arg == "--json" && !self.json {
self.json = true;
} else if arg == "--all" && self.id.is_some() && self.delete_comment.is_none() && !self.all
{
self.all = true;
} else {
return Err(format!(
"unexpected review argument: {}",
arg.to_string_lossy()
));
}
Ok(())
}
}
fn envelope(review: &Review, all: bool, comments: bool) -> Value {
let resolved = review
.comments
.iter()
.filter(|comment| comment.resolved)
.count();
let mut value = json!({
"repository": review.root,
"id": review.storage_id,
"revision": review.storage_revision,
"summary": review.summary,
"verdict": review.verdict,
"counts": { "total": review.comments.len(), "resolved": resolved, "unresolved": review.comments.len() - resolved },
});
if comments {
value["comments"] = review.comments.iter().filter(|comment| all || !comment.resolved).map(|comment| {
json!({"id": comment.id, "body": comment.body, "resolved": comment.resolved,
"anchor": { "revision": comment.anchor.revision, "path": comment.anchor.path,
"side": comment.anchor.side, "start": comment.anchor.start, "end": comment.anchor.end },
"excerpt": comment.anchor.excerpt()})
}).collect();
}
value
}
pub fn run(request: &Request, config: Option<&Path>) -> Result<(), String> {
let directory = config
.map(ruby::load_config)
.transpose()?
.and_then(|config| config.review_directory)
.map_or_else(vamp::review::default_directory, Ok)?;
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 store = Store::open_read_only(&directory)?;
let reviews = if let Some(id) = request.id {
let mut review = store
.as_ref()
.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)?;
if let Some(comment_id) = request.delete_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}"))?;
review.comments.remove(index);
Store::open(&directory)?.save(&mut review)?;
}
vec![review]
} else {
match store {
Some(store) => store.list(&root)?,
None => Vec::new(),
}
};
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!(
"{}",
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 {
println!("Repository: {}", root.display());
if reviews.is_empty() {
println!("No stored reviews.");
}
for review in &reviews {
print!("{}", review.summary_text());
println!(
"\nShow comments: vamp --review show {}",
review.storage_id.unwrap()
);
}
if !reviews.is_empty() {
println!("Include resolved comments: add --all");
}
}
Ok(())
}
#[cfg(test)]
#[path = "../tests/unit/review_cli.rs"]
mod tests;
+92 -2026
View File
File diff suppressed because it is too large Load Diff
+153 -1
View File
@@ -120,6 +120,9 @@ module Vamp
# @return [Hash{String => Boolean, Hash{String, Symbol => Object}}] language presets/options # @return [Hash{String => Boolean, Hash{String, Symbol => Object}}] language presets/options
attr_reader :lsp attr_reader :lsp
# @return [Hash{String => Integer, nil}] LSP-style language IDs to Tab widths
attr_reader :language_tab_spaces
# @return [Vamp::BarConfig] # @return [Vamp::BarConfig]
attr_reader :bar attr_reader :bar
@@ -130,6 +133,7 @@ module Vamp
def initialize def initialize
@plugins = [] @plugins = []
@lsp = {} @lsp = {}
@review_directory = nil
@bindings = [] @bindings = []
@leader = "space" @leader = "space"
@file_explorer_side = "right" @file_explorer_side = "right"
@@ -142,14 +146,22 @@ module Vamp
@popup = PopupConfig.new @popup = PopupConfig.new
@labels = {} @labels = {}
@scroll_distance = 0 @scroll_distance = 0
@scroll_step_lines = nil
@highlight_trailing_whitespace = false @highlight_trailing_whitespace = false
@whitespace_characters = {} @whitespace_characters = {}
@whitespace_scope = "all" @whitespace_scope = "all"
@tab_spaces = nil
@language_tab_spaces = {}
@diff_context_lines = 10 @diff_context_lines = 10
@diff_expand_lines = 15 @diff_expand_lines = 15
@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"
end end
# @param path [String] relative to the configuration file # @param path [String] relative to the configuration file
@@ -164,6 +176,15 @@ module Vamp
nil nil
end end
# @param path [String, nil] relative to the configuration file; nil uses the XDG data directory
# @return [String, nil]
def review_directory=(path)
path = nonempty_string!(path, "review_directory") unless path.nil?
raise ArgumentError, "review_directory must not contain NUL" if path&.include?("\0")
@review_directory = path
end
# @param key [String] # @param key [String]
# @return [String] # @return [String]
def leader=(key) def leader=(key)
@@ -224,6 +245,12 @@ module Vamp
@scroll_distance = distance.nil? ? nil : diff_line_count!(distance, "scroll_distance", 0) @scroll_distance = distance.nil? ? nil : diff_line_count!(distance, "scroll_distance", 0)
end end
# @param lines [Integer, nil] positive screen rows, or nil for half the viewport
# @return [Integer, nil]
def scroll_step_lines=(lines)
@scroll_step_lines = lines.nil? ? nil : diff_line_count!(lines, "scroll_step_lines", 1)
end
# @param enabled [Boolean] # @param enabled [Boolean]
# @return [Boolean] # @return [Boolean]
def highlight_trailing_whitespace=(enabled) def highlight_trailing_whitespace=(enabled)
@@ -254,6 +281,18 @@ module Vamp
@whitespace_scope = scope @whitespace_scope = scope
end end
# @param spaces [Integer, nil] spaces per Tab, or nil for a literal tab
# @return [Integer, nil]
def tab_spaces=(spaces)
@tab_spaces = tab_spaces!(spaces)
end
# @param languages [Hash{String => Integer, nil}] LSP-style language IDs to Tab widths
# @return [Hash{String => Integer, nil}]
def language_tab_spaces=(languages)
@language_tab_spaces = language_tab_spaces!(languages)
end
# @param lines [Integer] nonnegative context lines around each diff change # @param lines [Integer] nonnegative context lines around each diff change
# @return [Integer] # @return [Integer]
def diff_context_lines=(lines) def diff_context_lines=(lines)
@@ -292,6 +331,82 @@ 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"
# @return [String]
def resolve_history_layout=(layout)
layout = nonempty_string!(layout, "resolve history layout")
unless %w[columns base_below].include?(layout)
raise ArgumentError, "resolve history layout must be columns or base_below"
end
@resolve_history_layout = layout
end
# @return [Module<Vamp::Core>] frozen namespace with catalog-generated Action methods # @return [Module<Vamp::Core>] frozen namespace with catalog-generated Action methods
def core def core
Core Core
@@ -321,22 +436,59 @@ module Vamp
# @return [Hash{String => Object}] validated startup configuration frame # @return [Hash{String => Object}] validated startup configuration frame
def response def response
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings, { "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings,
"review_directory" => @review_directory,
"leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view, "leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view,
"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,
"scroll_distance" => @scroll_distance, "scroll_distance" => @scroll_distance,
"scroll_step_lines" => @scroll_step_lines,
"highlight_trailing_whitespace" => @highlight_trailing_whitespace, "highlight_trailing_whitespace" => @highlight_trailing_whitespace,
"whitespace_characters" => @whitespace_characters, "whitespace_characters" => @whitespace_characters,
"whitespace_scope" => @whitespace_scope, "whitespace_scope" => @whitespace_scope,
"tab_spaces" => @tab_spaces, "language_tab_spaces" => language_tab_spaces!(@language_tab_spaces),
"top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels, "top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels,
"popup" => { "command" => @popup.command, "search" => @popup.search }, "popup" => { "command" => @popup.command, "search" => @popup.search },
"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 }
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]
# @return [Integer, nil]
def tab_spaces!(spaces)
unless spaces.nil? || (spaces.is_a?(Integer) && spaces.between?(1, 255))
raise ArgumentError, "tab_spaces must be nil or an Integer between 1 and 255"
end
spaces
end
# @param languages [Hash{String => Integer, nil}]
# @return [Hash{String => Integer, nil}]
def language_tab_spaces!(languages)
raise TypeError, "language_tab_spaces must be a Hash" unless languages.is_a?(Hash)
languages.to_h do |language, spaces|
language = nonempty_string!(language, "Tab language")
raise ArgumentError, "Tab language must not contain control characters" if language.match?(/[[:cntrl:]]/)
[language, tab_spaces!(spaces)]
end
end
# @param value [Integer] # @param value [Integer]
# @param label [String] # @param label [String]
# @param minimum [Integer] # @param minimum [Integer]
+537 -3463
View File
File diff suppressed because it is too large Load Diff
+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;
+2 -121
View File
@@ -362,124 +362,5 @@ impl Render<'_> {
#[cfg(test)] #[cfg(test)]
// These fixtures intentionally contain ranges, not lists of indices. // These fixtures intentionally contain ranges, not lists of indices.
#[allow(clippy::single_range_in_vec_init)] #[allow(clippy::single_range_in_vec_init)]
mod tests { #[path = "../tests/unit/snippet.rs"]
use super::*; mod tests;
#[test]
fn grammar_expands_nested_mirrors_choices_variables_and_normalized_offsets() {
let (text, snippet) = expand(
"call($1, ${1:λ${2:x}}, ${3|a\\,b,c|}, ${UNKNOWN:${TM_LINE_NUMBER}})\r\n$0\\$",
|name| (name == "TM_LINE_NUMBER").then(|| "4".to_owned()),
)
.unwrap();
assert_eq!(text, "call(λx, λx, a,b, 4)\n$");
assert_eq!(
snippet.tabstops,
vec![
SnippetTabstop {
index: 1,
ranges: vec![5..8, 10..13],
ancestors: vec![vec![], vec![]]
},
SnippetTabstop {
index: 2,
ranges: vec![7..8, 12..13],
ancestors: vec![vec![1], vec![1]]
},
SnippetTabstop {
index: 3,
ranges: vec![15..18],
ancestors: vec![vec![]]
},
SnippetTabstop {
index: 0,
ranges: vec![23..23],
ancestors: vec![vec![]]
},
]
);
let (text, snippet) = expand("call($1, ${2})", |_| None).unwrap();
assert_eq!(text, "call(, )");
assert_eq!(
snippet.tabstops,
vec![
SnippetTabstop {
index: 1,
ranges: vec![5..5],
ancestors: vec![vec![]]
},
SnippetTabstop {
index: 2,
ranges: vec![7..7],
ancestors: vec![vec![]]
},
SnippetTabstop {
index: 0,
ranges: vec![8..8],
ancestors: vec![vec![]]
},
]
);
assert_eq!(
expand("${KNOWN:${1:hidden}}$1", |name| {
(name == "KNOWN").then(|| "shown".to_owned())
})
.unwrap()
.0,
"shown"
);
let (text, snippet) = expand("call(${CTX})$1", |name| {
(name == "CTX").then(|| "a\r\nb\rc".to_owned())
})
.unwrap();
assert_eq!(text, "call(a\nb\nc)");
assert_eq!(snippet.tabstops[0].ranges, [11..11]);
assert_eq!(
expand("${1|a\\|b,c|}\\}\\\\", |_| None).unwrap().0,
"a|b}\\"
);
for input in ["${1", "${1:$1}", "${1:a}${1:b}$1", "${1:e}\u{301}"] {
if input == "${1:a}${1:b}$1" {
assert_eq!(expand(input, |_| None).unwrap().0, "aaa");
} else {
assert_eq!(expand(input, |_| None), Err(Error::Invalid));
}
}
let (text, snippet) = expand("${1}$1", |_| None).unwrap();
assert!(text.is_empty());
assert_eq!(
snippet.tabstops[0],
SnippetTabstop {
index: 1,
ranges: vec![0..0, 0..0],
ancestors: vec![vec![], vec![]],
}
);
for input in [
"$UNKNOWN",
"${TM_FILENAME/(.*)/$1/}",
"${1/a/b/}",
"${1:a$0b}",
"$0$0",
"${0:arg}",
"${0:$1}",
] {
assert_eq!(expand(input, |_| None), Err(Error::Unsupported));
}
assert_eq!(
expand(&"$1".repeat(MAX_STOPS + 1), |_| None),
Err(Error::Limit)
);
assert_eq!(
expand(
&format!(
"{}x{}",
"${1:".repeat(MAX_DEPTH + 1),
"}".repeat(MAX_DEPTH + 1)
),
|_| None
),
Err(Error::Limit)
);
}
}
+440 -940
View File
File diff suppressed because it is too large Load Diff
+87 -77
View File
@@ -3,6 +3,7 @@ use std::path::Path;
use std::sync::LazyLock; use std::sync::LazyLock;
pub(super) struct LanguageSpec { pub(super) struct LanguageSpec {
pub id: &'static str,
pub name: &'static str, pub name: &'static str,
pub language: tree_sitter::Language, pub language: tree_sitter::Language,
pub highlights: &'static str, pub highlights: &'static str,
@@ -73,6 +74,13 @@ static MD_HIGHLIGHTS: LazyLock<String> = LazyLock::new(|| {
.join("\n") .join("\n")
}); });
// Upstream's Lua patterns are regex-compatible; spell-check metadata is not a color.
static ZIG_HIGHLIGHTS: LazyLock<String> = LazyLock::new(|| {
tree_sitter_zig::HIGHLIGHTS_QUERY
.replace("#lua-match?", "#match?")
.replace(" @spell", "")
});
// The MIT-licensed upstream C++ crate bundles, but does not export, injections.scm. // The MIT-licensed upstream C++ crate bundles, but does not export, injections.scm.
// This minimal AST query uses its raw-string delimiter/content nodes: // This minimal AST query uses its raw-string delimiter/content nodes:
// https://github.com/tree-sitter/tree-sitter-cpp/blob/v0.23.4/queries/injections.scm // https://github.com/tree-sitter/tree-sitter-cpp/blob/v0.23.4/queries/injections.scm
@@ -82,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;
@@ -93,6 +127,7 @@ fn javascript_injections() -> &'static str {
} }
fn spec( fn spec(
id: &'static str,
name: &'static str, name: &'static str,
language: tree_sitter::Language, language: tree_sitter::Language,
highlights: &'static str, highlights: &'static str,
@@ -100,6 +135,7 @@ fn spec(
locals: &'static str, locals: &'static str,
) -> LanguageSpec { ) -> LanguageSpec {
LanguageSpec { LanguageSpec {
id,
name, name,
language, language,
highlights, highlights,
@@ -108,9 +144,10 @@ fn spec(
} }
} }
static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| { static LANGUAGES: LazyLock<[LanguageSpec; 21]> = LazyLock::new(|| {
[ [
spec( spec(
super::RUST_ID,
"Rust", "Rust",
tree_sitter_rust::LANGUAGE.into(), tree_sitter_rust::LANGUAGE.into(),
tree_sitter_rust::HIGHLIGHTS_QUERY, tree_sitter_rust::HIGHLIGHTS_QUERY,
@@ -118,6 +155,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"", "",
), ),
spec( spec(
super::RUBY_ID,
"Ruby", "Ruby",
tree_sitter_ruby::LANGUAGE.into(), tree_sitter_ruby::LANGUAGE.into(),
tree_sitter_ruby::HIGHLIGHTS_QUERY, tree_sitter_ruby::HIGHLIGHTS_QUERY,
@@ -125,6 +163,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
tree_sitter_ruby::LOCALS_QUERY, tree_sitter_ruby::LOCALS_QUERY,
), ),
spec( spec(
super::PYTHON_ID,
"Python", "Python",
tree_sitter_python::LANGUAGE.into(), tree_sitter_python::LANGUAGE.into(),
tree_sitter_python::HIGHLIGHTS_QUERY, tree_sitter_python::HIGHLIGHTS_QUERY,
@@ -132,6 +171,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"", "",
), ),
spec( spec(
"json",
"JSON", "JSON",
tree_sitter_json::LANGUAGE.into(), tree_sitter_json::LANGUAGE.into(),
tree_sitter_json::HIGHLIGHTS_QUERY, tree_sitter_json::HIGHLIGHTS_QUERY,
@@ -139,6 +179,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"", "",
), ),
spec( spec(
"shellscript",
"Bash", "Bash",
tree_sitter_bash::LANGUAGE.into(), tree_sitter_bash::LANGUAGE.into(),
tree_sitter_bash::HIGHLIGHT_QUERY, tree_sitter_bash::HIGHLIGHT_QUERY,
@@ -146,6 +187,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"", "",
), ),
spec( spec(
"makefile",
"Makefile", "Makefile",
tree_sitter_make::LANGUAGE.into(), tree_sitter_make::LANGUAGE.into(),
tree_sitter_make::HIGHLIGHTS_QUERY, tree_sitter_make::HIGHLIGHTS_QUERY,
@@ -153,6 +195,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"", "",
), ),
spec( spec(
"markdown",
"Markdown", "Markdown",
tree_sitter_md::LANGUAGE.into(), tree_sitter_md::LANGUAGE.into(),
MD_HIGHLIGHTS.as_str(), MD_HIGHLIGHTS.as_str(),
@@ -160,6 +203,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"", "",
), ),
spec( spec(
"javascript",
"JavaScript", "JavaScript",
tree_sitter_javascript::LANGUAGE.into(), tree_sitter_javascript::LANGUAGE.into(),
JS_HIGHLIGHTS.as_str(), JS_HIGHLIGHTS.as_str(),
@@ -167,6 +211,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
tree_sitter_javascript::LOCALS_QUERY, tree_sitter_javascript::LOCALS_QUERY,
), ),
spec( spec(
"typescript",
"TypeScript", "TypeScript",
tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
TS_HIGHLIGHTS.as_str(), TS_HIGHLIGHTS.as_str(),
@@ -174,6 +219,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
TS_LOCALS.as_str(), TS_LOCALS.as_str(),
), ),
spec( spec(
"html",
"HTML", "HTML",
tree_sitter_html::LANGUAGE.into(), tree_sitter_html::LANGUAGE.into(),
tree_sitter_html::HIGHLIGHTS_QUERY, tree_sitter_html::HIGHLIGHTS_QUERY,
@@ -181,6 +227,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"", "",
), ),
spec( spec(
"css",
"CSS", "CSS",
tree_sitter_css::LANGUAGE.into(), tree_sitter_css::LANGUAGE.into(),
tree_sitter_css::HIGHLIGHTS_QUERY, tree_sitter_css::HIGHLIGHTS_QUERY,
@@ -188,6 +235,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"", "",
), ),
spec( spec(
"yaml",
"YAML", "YAML",
tree_sitter_yaml::LANGUAGE.into(), tree_sitter_yaml::LANGUAGE.into(),
tree_sitter_yaml::HIGHLIGHTS_QUERY, tree_sitter_yaml::HIGHLIGHTS_QUERY,
@@ -195,6 +243,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"", "",
), ),
spec( spec(
"toml",
"TOML", "TOML",
tree_sitter_toml_ng::LANGUAGE.into(), tree_sitter_toml_ng::LANGUAGE.into(),
tree_sitter_toml_ng::HIGHLIGHTS_QUERY, tree_sitter_toml_ng::HIGHLIGHTS_QUERY,
@@ -202,6 +251,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"", "",
), ),
spec( spec(
"c",
"C", "C",
tree_sitter_c::LANGUAGE.into(), tree_sitter_c::LANGUAGE.into(),
tree_sitter_c::HIGHLIGHT_QUERY, tree_sitter_c::HIGHLIGHT_QUERY,
@@ -209,6 +259,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"", "",
), ),
spec( spec(
"cpp",
"C++", "C++",
tree_sitter_cpp::LANGUAGE.into(), tree_sitter_cpp::LANGUAGE.into(),
CPP_HIGHLIGHTS.as_str(), CPP_HIGHLIGHTS.as_str(),
@@ -216,6 +267,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"", "",
), ),
spec( spec(
"go",
"Go", "Go",
tree_sitter_go::LANGUAGE.into(), tree_sitter_go::LANGUAGE.into(),
tree_sitter_go::HIGHLIGHTS_QUERY, tree_sitter_go::HIGHLIGHTS_QUERY,
@@ -223,6 +275,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"", "",
), ),
spec( spec(
"typescriptreact",
"TypeScript", "TypeScript",
tree_sitter_typescript::LANGUAGE_TSX.into(), tree_sitter_typescript::LANGUAGE_TSX.into(),
TSX_HIGHLIGHTS.as_str(), TSX_HIGHLIGHTS.as_str(),
@@ -230,12 +283,37 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
TS_LOCALS.as_str(), TS_LOCALS.as_str(),
), ),
spec( spec(
"markdown",
"Markdown inline", "Markdown inline",
tree_sitter_md::INLINE_LANGUAGE.into(), tree_sitter_md::INLINE_LANGUAGE.into(),
tree_sitter_md::HIGHLIGHT_QUERY_INLINE, tree_sitter_md::HIGHLIGHT_QUERY_INLINE,
tree_sitter_md::INJECTION_QUERY_INLINE, tree_sitter_md::INJECTION_QUERY_INLINE,
"", "",
), ),
spec(
"zig",
"Zig",
tree_sitter_zig::LANGUAGE.into(),
ZIG_HIGHLIGHTS.as_str(),
tree_sitter_zig::INJECTIONS_QUERY,
"",
),
spec(
"honey",
"Honey",
tree_sitter_honey::LANGUAGE.into(),
tree_sitter_honey::HIGHLIGHTS_QUERY,
"",
"",
),
spec(
super::TYPST_ID,
"Typst",
tree_sitter_typst::LANGUAGE.into(),
TYPST_HIGHLIGHTS,
TYPST_INJECTIONS,
"",
),
] ]
}); });
@@ -274,6 +352,9 @@ pub(super) fn by_name(name: &str) -> Option<&'static LanguageSpec> {
("cpp", "C++"), ("cpp", "C++"),
("cxx", "C++"), ("cxx", "C++"),
("golang", "Go"), ("golang", "Go"),
("hon", "Honey"),
("honeylang", "Honey"),
("typ", "Typst"),
]; ];
let name = ALIASES let name = ALIASES
.iter() .iter()
@@ -328,6 +409,9 @@ fn detect_path(path: &Path) -> Option<&'static LanguageSpec> {
"cc" | "cpp" | "cxx" | "c++" | "hh" | "hpp" | "hxx" | "h++" | "ipp" | "inl" "cc" | "cpp" | "cxx" | "c++" | "hh" | "hpp" | "hxx" | "h++" | "ipp" | "inl"
| "tpp" => "C++", | "tpp" => "C++",
"go" => "Go", "go" => "Go",
"zig" => "Zig",
"hon" => "Honey",
"typ" => "Typst",
_ => return None, _ => return None,
} }
} }
@@ -370,79 +454,5 @@ fn detect_shebang(line: &str) -> Option<&'static LanguageSpec> {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "../../tests/unit/syntax_languages.rs"]
use super::*; mod tests;
#[test]
fn selected_files_and_auxiliary_grammars_are_registered() {
assert_eq!(all().count(), 18);
assert!(all().all(|spec| !spec.highlights.is_empty()));
for (filename, language) in [
("main.rs", "Rust"),
("Gemfile", "Ruby"),
("main.py", "Python"),
("package.json", "JSON"),
(".bashrc", "Bash"),
("GNUmakefile", "Makefile"),
("README.md", "Markdown"),
("main.jsx", "JavaScript"),
("main.ts", "TypeScript"),
("index.html", "HTML"),
("style.css", "CSS"),
("config.yml", "YAML"),
("Cargo.toml", "TOML"),
("Cargo.lock", "TOML"),
("main.c", "C"),
("main.cpp", "C++"),
("main.C", "C++"),
("main.go", "Go"),
] {
assert_eq!(
detect(Some(Path::new(filename)), None).map(|spec| spec.name),
Some(language)
);
}
let ts = detect(Some(Path::new("main.ts")), None).unwrap();
let tsx = detect(Some(Path::new("main.tsx")), None).unwrap();
assert_eq!(ts.name, tsx.name);
assert!(!std::ptr::eq(ts, tsx));
assert_ne!(ts.language, tsx.language);
assert!(std::ptr::eq(tsx, by_name("TSX").unwrap()));
assert!(by_name("markdown_inline").is_some());
for filename in [
"notes.txt",
"main.hs",
"main.scala",
"main.lua",
"Dockerfile",
] {
assert!(detect(Some(Path::new(filename)), None).is_none());
}
}
#[test]
fn shebangs_support_bom_env_options_and_filename_precedence() {
for (line, language) in [
("#!/usr/bin/python3", "Python"),
("\u{feff}#!/usr/bin/env -S python3.12 -u", "Python"),
("#!/usr/bin/env -u PYTHONHOME FOO=bar python3", "Python"),
("#!/usr/bin/ruby3.3", "Ruby"),
("#!/bin/bash", "Bash"),
("#! /usr/bin/env node", "JavaScript"),
] {
assert_eq!(
detect(None, Some(line)).map(|spec| spec.name),
Some(language)
);
}
assert_eq!(
detect(Some(Path::new("main.rs")), Some("#!/bin/bash"))
.unwrap()
.name,
"Rust"
);
assert!(detect(None, Some("#!/usr/bin/python-not-real")).is_none());
assert!(detect(None, Some("#!/usr/bin/perl")).is_none());
assert!(detect(None, Some("plain text")).is_none());
}
}
+2 -275
View File
@@ -295,278 +295,5 @@ fn hex_value(component: &[u8]) -> Option<u32> {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "../tests/unit/terminal_colors.rs"]
use super::*; mod tests;
use crossterm::event::{KeyEvent, MouseEvent, MouseEventKind};
fn key(code: KeyCode, modifiers: KeyModifiers) -> Event {
Event::Key(KeyEvent::new(code, modifiers))
}
fn bytes(collector: &mut TerminalColors, text: &str, now: Instant) {
for ch in text.chars() {
let modifiers = if ch.is_ascii_uppercase() {
KeyModifiers::SHIFT
} else {
KeyModifiers::NONE
};
collector.push(key(KeyCode::Char(ch), modifiers), now);
}
}
fn report(collector: &mut TerminalColors, body: &str, now: Instant) {
collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), now);
bytes(collector, body, now);
collector.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL), now);
}
fn drain(collector: &mut TerminalColors) -> Vec<Event> {
std::iter::from_fn(|| collector.pop()).collect()
}
#[test]
fn fragmented_triple_accepts_bel_and_both_st_encodings() {
let now = Instant::now();
let mut collector = TerminalColors::default();
collector.query(&mut Vec::new(), now).unwrap();
collector.push(key(KeyCode::Esc, KeyModifiers::NONE), now);
collector.push(
key(KeyCode::Char(']'), KeyModifiers::NONE),
now + Duration::from_millis(1),
);
bytes(
&mut collector,
"11;rgb:0/12/345",
now + Duration::from_millis(2),
);
collector.push(
key(KeyCode::Esc, KeyModifiers::NONE),
now + Duration::from_millis(3),
);
collector.push(
key(KeyCode::Char('\\'), KeyModifiers::NONE),
now + Duration::from_millis(4),
);
assert_eq!(collector.colors, None);
report(
&mut collector,
"4;2;rgb:f/ffff/ABCD",
now + Duration::from_millis(5),
);
collector.push(
key(KeyCode::Char(']'), KeyModifiers::ALT),
now + Duration::from_millis(6),
);
bytes(
&mut collector,
"4;1;#FF1234",
now + Duration::from_millis(7),
);
collector.push(
key(KeyCode::Char('\\'), KeyModifiers::ALT),
now + Duration::from_millis(8),
);
assert_eq!(
collector.colors,
Some(ThemeColors {
background: [0, 18, 52],
red: [255, 18, 52],
green: [255, 255, 171],
})
);
assert!(drain(&mut collector).is_empty());
}
#[test]
fn focus_refresh_atomically_replaces_light_and_dark_triples() {
let now = Instant::now();
let mut collector = TerminalColors::default();
let palettes = [
("11;#FDF6E3", [253, 246, 227]),
("11;rgb:0000/0000/0000", [0, 0, 0]),
];
for (cycle, (background, expected)) in palettes.into_iter().enumerate() {
let time = now + Duration::from_secs(cycle as u64);
let focus = Event::FocusGained;
collector.push(focus.clone(), time);
assert_eq!(collector.pop(), Some(focus));
let previous = collector.colors;
collector.query(&mut Vec::new(), time).unwrap();
report(&mut collector, "4;1;rgb:ff/00/00", time);
report(&mut collector, background, time);
assert_eq!(collector.colors, previous);
report(&mut collector, "4;2;rgb:00/ff/00", time);
assert_eq!(
collector.colors,
Some(ThemeColors {
background: expected,
red: [255, 0, 0],
green: [0, 255, 0],
})
);
assert!(drain(&mut collector).is_empty());
}
}
#[test]
fn periodic_refresh_detects_silent_change_without_overlapping_queries() {
let now = Instant::now();
let mut collector = TerminalColors::default();
let mut output = Vec::new();
assert!(collector.refresh_due(now));
collector.query(&mut output, now).unwrap();
report(&mut collector, "11;#101010", now);
report(&mut collector, "4;1;#FF0000", now);
report(&mut collector, "4;2;#00FF00", now);
let previous = collector.colors;
assert!(previous.is_some());
// The terminal changes its palette without emitting a notification.
let refresh = now + Duration::from_secs(1);
assert!(!collector.refresh_due(refresh - Duration::from_nanos(1)));
assert!(collector.refresh_due(refresh));
collector.query(&mut output, refresh).unwrap();
report(&mut collector, "11;#EEEEEE", refresh);
report(&mut collector, "4;1;#AA1122", refresh);
assert_eq!(collector.colors, previous);
let typed = key(KeyCode::Char('i'), KeyModifiers::NONE);
collector.push(typed.clone(), refresh);
assert_eq!(drain(&mut collector), vec![typed]);
// Even an overdue probe must wait until the incomplete query expires.
let retry = refresh + Duration::from_secs(1);
assert!(!collector.refresh_due(retry));
collector.expire(retry);
assert_eq!(collector.colors, None);
assert!(collector.refresh_due(retry));
collector.query(&mut output, retry).unwrap();
report(&mut collector, "11;#EEEEEE", retry);
report(&mut collector, "4;1;#AA1122", retry);
assert_eq!(collector.colors, None);
report(&mut collector, "4;2;#22BB33", retry);
assert_eq!(
collector.colors,
Some(ThemeColors {
background: [238, 238, 238],
red: [170, 17, 34],
green: [34, 187, 51],
})
);
assert!(drain(&mut collector).is_empty());
assert!(!collector.refresh_due(retry + Duration::from_millis(999)));
assert!(collector.refresh_due(retry + Duration::from_secs(1)));
}
#[test]
fn failed_refresh_clears_stale_colors_and_consumes_late_partial_reply() {
let now = Instant::now();
let mut collector = TerminalColors::default();
collector.query(&mut Vec::new(), now).unwrap();
report(&mut collector, "11;#101010", now);
report(&mut collector, "4;1;#FF0000", now);
report(&mut collector, "4;2;#00FF00", now);
assert!(collector.colors.is_some());
let refresh = now + Duration::from_secs(1);
collector.query(&mut Vec::new(), refresh).unwrap();
report(&mut collector, "11;#EEEEEE", refresh);
collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), refresh);
bytes(&mut collector, "4;1;rgb:ffff/", refresh);
collector.expire(refresh + QUERY_TIMEOUT);
assert_eq!(collector.colors, None);
let late = refresh + Duration::from_secs(1);
bytes(&mut collector, "0000/0000", late);
collector.push(key(KeyCode::Esc, KeyModifiers::NONE), late);
collector.expire(late + Duration::from_secs(1));
collector.push(
key(KeyCode::Char('\\'), KeyModifiers::NONE),
late + Duration::from_secs(1),
);
report(&mut collector, "4;2;#00FF00", late + Duration::from_secs(1));
assert_eq!(collector.colors, None);
assert!(drain(&mut collector).is_empty());
let typed = key(KeyCode::Char('i'), KeyModifiers::NONE);
collector.push(typed.clone(), late + Duration::from_secs(2));
assert_eq!(collector.pop(), Some(typed));
}
#[test]
fn unrelated_events_and_rejected_candidates_replay_exactly() {
let now = Instant::now();
let mut collector = TerminalColors::default();
collector.query(&mut Vec::new(), now).unwrap();
let events = vec![
key(KeyCode::Char(']'), KeyModifiers::ALT),
key(KeyCode::Char('4'), KeyModifiers::NONE),
key(KeyCode::Char(';'), KeyModifiers::NONE),
key(KeyCode::Char('3'), KeyModifiers::NONE),
key(KeyCode::Char('g'), KeyModifiers::CONTROL),
key(KeyCode::Char(']'), KeyModifiers::ALT | KeyModifiers::SHIFT),
Event::Key(KeyEvent::new_with_kind(
KeyCode::Char(']'),
KeyModifiers::ALT,
KeyEventKind::Release,
)),
Event::Key(KeyEvent::new_with_kind(
KeyCode::Char('A'),
KeyModifiers::SHIFT,
KeyEventKind::Repeat,
)),
Event::Key(KeyEvent {
code: KeyCode::Char('a'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
state: KeyEventState::CAPS_LOCK,
}),
Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
column: 2,
row: 3,
modifiers: KeyModifiers::CONTROL,
}),
Event::FocusLost,
Event::Resize(80, 24),
key(KeyCode::Char(']'), KeyModifiers::ALT),
Event::Paste("\x1b]11;rgb:ff/ff/ff\x07".into()),
Event::FocusGained,
];
for event in &events {
collector.push(event.clone(), now);
}
assert_eq!(drain(&mut collector), events);
assert_eq!(collector.colors, None);
}
#[test]
fn malformed_or_oversized_reports_and_lone_escape_do_not_eat_input() {
let now = Instant::now();
let mut collector = TerminalColors::default();
for body in [
"11;rgb:12345/0/0",
"11;rgb:1//3",
"11;#12345",
"11;rgb:1/2/3/4",
] {
let mut expected = vec![key(KeyCode::Char(']'), KeyModifiers::ALT)];
expected.extend(
body.chars()
.map(|ch| key(KeyCode::Char(ch), KeyModifiers::NONE)),
);
expected.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL));
for event in &expected {
collector.push(event.clone(), now);
}
assert_eq!(drain(&mut collector), expected);
}
let escape = key(KeyCode::Esc, KeyModifiers::NONE);
collector.push(escape.clone(), now);
assert_eq!(collector.pop(), None);
collector.expire(now + ESCAPE_TIMEOUT);
assert_eq!(collector.pop(), Some(escape));
assert_eq!(collector.pop(), None);
let start = key(KeyCode::Char(']'), KeyModifiers::ALT);
collector.push(start.clone(), now + Duration::from_secs(1));
collector.expire(now + Duration::from_secs(1) + QUERY_TIMEOUT);
assert_eq!(collector.pop(), Some(start));
assert_eq!(collector.pop(), None);
}
}
+2311 -12985
View File
File diff suppressed because it is too large Load Diff
+7 -112
View File
@@ -36,6 +36,7 @@ impl WrapCache {
let mut start = 0; let mut start = 0;
let mut column = 0; let mut column = 0;
let mut word_boundary = 0; let mut word_boundary = 0;
let mut content_end = 0;
for grapheme in text.graphemes(true) { for grapheme in text.graphemes(true) {
let cells = if grapheme == "\t" { let cells = if grapheme == "\t" {
TAB_WIDTH - column % TAB_WIDTH TAB_WIDTH - column % TAB_WIDTH
@@ -64,7 +65,10 @@ impl WrapCache {
} }
} }
column += cells; column += cells;
if grapheme.chars().all(char::is_whitespace) { if !grapheme.chars().all(char::is_whitespace) {
content_end = column;
} else if content_end > start {
// A word break must not leave only whitespace on this visual row.
word_boundary = column; word_boundary = column;
} }
} }
@@ -88,114 +92,5 @@ pub(crate) fn caret_segment(segments: &[Segment], column: usize) -> usize {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "../tests/unit/wrap.rs"]
use super::*; mod tests;
fn layout(text: &str, width: usize) -> Vec<Segment> {
let mut cache = WrapCache::default();
cache.prepare((1, 0), width);
cache.segments(0, text).to_vec()
}
#[test]
fn words_and_long_words_preserve_every_source_cell() {
assert_eq!(
layout("one two three", 8),
vec![Segment { start: 0, end: 8 }, Segment { start: 8, end: 13 },]
);
let segments = layout("abcdefgh", 3);
assert_eq!(
segments,
vec![
Segment { start: 0, end: 3 },
Segment { start: 3, end: 6 },
Segment { start: 6, end: 8 },
]
);
assert_eq!(caret_segment(&segments, 3), 1);
assert_eq!(caret_segment(&segments, 8), 2);
}
#[test]
fn tabs_use_source_stops_and_unicode_graphemes_remain_intact() {
assert_eq!(
layout("ab\t界e\u{301} z", 4),
vec![
Segment { start: 0, end: 4 },
Segment { start: 4, end: 8 },
Segment { start: 8, end: 9 },
]
);
assert_eq!(
layout("界界界", 3),
vec![
Segment { start: 0, end: 2 },
Segment { start: 2, end: 4 },
Segment { start: 4, end: 6 },
]
);
assert_eq!(
layout(" \t ", 3),
vec![Segment { start: 0, end: 3 }, Segment { start: 3, end: 5 },]
);
}
#[test]
fn narrow_tabs_preserve_all_expanded_cells_at_original_stops() {
assert_eq!(
layout("\tX", 2),
[
Segment { start: 0, end: 2 },
Segment { start: 2, end: 4 },
Segment { start: 4, end: 5 },
]
);
assert_eq!(
layout("a\tX", 2),
[
Segment { start: 0, end: 1 },
Segment { start: 1, end: 3 },
Segment { start: 3, end: 5 },
Segment { start: 5, end: 5 },
]
);
for source in ["\t", "a\t", "ab\tX", "\t\tX"] {
let segments = layout(source, 1);
assert!(
segments
.iter()
.all(|segment| segment.end - segment.start <= 1)
);
assert!(segments.windows(2).all(|pair| pair[0].end == pair[1].start));
let end = source.graphemes(true).fold(0, |column, grapheme| {
column
+ if grapheme == "\t" {
TAB_WIDTH - column % TAB_WIDTH
} else {
1
}
});
assert_eq!(segments.last().unwrap(), &Segment { start: end, end });
}
}
#[test]
fn full_lines_have_an_insertion_cell_and_cache_invalidation_is_lazy() {
let mut cache = WrapCache::default();
cache.prepare((7, 2), 4);
assert_eq!(
cache.segments(90, "abcd"),
[Segment { start: 0, end: 4 }, Segment { start: 4, end: 4 },]
);
assert_eq!(caret_segment(cache.segments(90, "abcd"), 4), 1);
assert_eq!(cache.rows.len(), 1);
cache.prepare((7, 2), 4);
assert_eq!(cache.rows.len(), 1);
cache.prepare((7, 2), 8);
assert!(cache.rows.is_empty());
cache.segments(90, "abcd");
cache.prepare((7, 3), 8);
assert!(cache.rows.is_empty());
assert_eq!(layout("", 0), [Segment { start: 0, end: 0 }]);
}
}
+90 -5
View File
@@ -49,6 +49,23 @@ impl Fixture {
); );
String::from_utf8(output.stdout).unwrap().trim().to_owned() String::from_utf8(output.stdout).unwrap().trim().to_owned()
} }
fn jj(&self, args: &[&str]) -> String {
let output = Command::new("jj")
.args(args)
.current_dir(self.0.join("repo"))
.env("JJ_CONFIG", "/dev/null")
.env("JJ_USER", "Diff Fixture")
.env("JJ_EMAIL", "fixture@example.invalid")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}
} }
impl Drop for Fixture { impl Drop for Fixture {
@@ -92,7 +109,7 @@ fn typed_diff_edits_unsaved_working_after_and_preserves_undo_on_explicit_close()
let config = fixture.0.join("config.rb"); let config = fixture.0.join("config.rb");
fs::write( fs::write(
&config, &config,
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.bind 'leader d', config.core.diff\nend\n", "Vamp.configure do |config|\n config.review_directory = 'reviews'\n config.clipboard_mode = 'separate'\n config.bind 'leader d', config.core.diff\nend\n",
) )
.unwrap(); .unwrap();
let mut session = let mut session =
@@ -211,7 +228,7 @@ fn tabs_keep_repository_workers_and_hidden_comparisons_independent() {
let config = first.0.join("config.rb"); let config = first.0.join("config.rb");
fs::write( fs::write(
&config, &config,
"Vamp.configure do |config|\n config.bind 'ctrl+option+n', config.core.tab.new\n config.bind 'ctrl+option+w', config.core.tab.close\nend\n", "Vamp.configure do |config|\n config.review_directory = 'reviews'\n config.bind 'ctrl+option+n', config.core.tab.new\n config.bind 'ctrl+option+w', config.core.tab.close\nend\n",
) )
.unwrap(); .unwrap();
let mut session = let mut session =
@@ -313,7 +330,7 @@ fn editable_diff_navigation_skips_omitted_lines_without_expanding_excerpts() {
let text = format!("{}\n", lines.join("\n")); let text = format!("{}\n", lines.join("\n"));
fs::write(&source, &text).unwrap(); fs::write(&source, &text).unwrap();
let config = fixture.0.join("config.rb"); let config = fixture.0.join("config.rb");
fs::write(&config, "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.diff_context_lines = 1\n config.diff_expand_lines = 2\n config.bind 'ctrl+f', config.core.down\n config.bind 'ctrl+b', config.core.up\nend\n").unwrap(); fs::write(&config, "Vamp.configure do |config|\n config.review_directory = 'reviews'\n config.clipboard_mode = 'separate'\n config.diff_context_lines = 1\n config.diff_expand_lines = 2\n config.bind 'ctrl+f', config.core.down\n config.bind 'ctrl+b', config.core.up\nend\n").unwrap();
let mut session = let mut session =
EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap(); EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap();
session.execute("diff"); session.execute("diff");
@@ -450,7 +467,14 @@ fn readonly_jump_history_restores_the_comparison_file_and_source_cursor() {
let text = fs::read_to_string(&path).unwrap(); let text = fs::read_to_string(&path).unwrap();
fs::write(path, text.replace("_before", "_after")).unwrap(); fs::write(path, text.replace("_before", "_after")).unwrap();
} }
let mut session = EditorSession::open(Some(root.join("first.rs")), vec![], None).unwrap(); let config = fixture.0.join("review-config.rb");
fs::write(
&config,
"Vamp.configure { |config| config.review_directory = 'reviews' }",
)
.unwrap();
let mut session =
EditorSession::open(Some(root.join("first.rs")), vec![], Some(&config)).unwrap();
session.execute("diff"); session.execute("diff");
wait_until(&mut session, |session| { wait_until(&mut session, |session| {
!session.editor().diff().unwrap().loading !session.editor().diff().unwrap().loading
@@ -513,7 +537,7 @@ fn branch_changes_default_and_mode_switches_compare_the_selected_divergent_tips(
fixture.git(&["commit", "-qm", "feature only"]); fixture.git(&["commit", "-qm", "feature only"]);
let after = fixture.git(&["rev-parse", "HEAD"]); let after = fixture.git(&["rev-parse", "HEAD"]);
let config = fixture.0.join("config.rb"); let config = fixture.0.join("config.rb");
fs::write(&config, "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.bind 'ctrl+b', config.core.diff.mode.toggle\nend\n").unwrap(); fs::write(&config, "Vamp.configure do |config|\n config.review_directory = 'reviews'\n config.clipboard_mode = 'separate'\n config.bind 'ctrl+b', config.core.diff.mode.toggle\nend\n").unwrap();
let mut session = let mut session =
EditorSession::open(Some(root.join("common")), vec![], Some(&config)).unwrap(); EditorSession::open(Some(root.join("common")), vec![], Some(&config)).unwrap();
session.execute("diff"); session.execute("diff");
@@ -610,3 +634,64 @@ fn branch_changes_default_and_mode_switches_compare_the_selected_divergent_tips(
"Working checkout requires Snapshots" "Working checkout requires Snapshots"
); );
} }
#[test]
fn jj_bookmark_picker_keeps_symbolic_targets_searchable_by_resolved_commit_id() {
use vamp::diff::RevisionTarget;
let fixture = Fixture::new();
fixture.jj(&["git", "init", "--no-colocate"]);
let source = fixture.0.join("repo/source.txt");
fs::write(&source, "base\n").unwrap();
fixture.jj(&["describe", "-m", "base"]);
let before = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
fixture.jj(&["new"]);
fs::write(&source, "topic\n").unwrap();
fixture.jj(&["describe", "-m", "topic"]);
fixture.jj(&["bookmark", "create", "topic"]);
let after = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
fixture.jj(&["new"]);
let config = fixture.0.join("review-config.rb");
fs::write(
&config,
"Vamp.configure { |config| config.review_directory = 'reviews' }",
)
.unwrap();
let mut session = EditorSession::open(Some(source), vec![], Some(&config)).unwrap();
session.execute("diff");
wait_until(&mut session, |session| {
!session.editor().diff().unwrap().loading
});
type_query(&mut session, &before);
session.handle(Input::Down);
session.handle(Input::Enter);
session.handle(Input::Char('\t'));
type_query(&mut session, &after);
let target = RevisionTarget::Bookmark {
name: "topic".into(),
remote: None,
};
assert_eq!(
session
.editor()
.diff()
.unwrap()
.entries()
.nth(1)
.unwrap()
.target,
target
);
session.handle(Input::Down);
session.handle(Input::Enter);
wait_until(&mut session, |session| {
session.editor().diff().unwrap().selected_file_loaded()
});
let diff = session.editor().diff().unwrap();
assert_eq!(
diff.before.as_ref().unwrap().target,
RevisionTarget::Commit(before)
);
assert_eq!(diff.after.as_ref().unwrap().target, target);
assert_eq!(diff.comparison.as_ref().unwrap().files[0].after, ["topic"]);
assert!(!diff.after_editable());
}
+46 -2
View File
@@ -374,7 +374,7 @@ impl Fixture {
"settings": {"rust-analyzer": {"cargo": {"allFeatures": true}}}, "settings": {"rust-analyzer": {"cargo": {"allFeatures": true}}},
}); });
let config = format!( let config = format!(
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.diff_context_lines = 1\n config.diff_expand_lines = 2\n config.lsp['fixture-language'] = JSON.parse({})\n config.bind 'leader d', config.core.lsp.definition\n config.bind 'leader D', config.core.lsp.diagnostics\n config.bind 'K', config.core.lsp.hover\nend\n", "Vamp.configure do |config|\n config.review_directory = 'reviews'\n config.clipboard_mode = 'separate'\n config.diff_context_lines = 1\n config.diff_expand_lines = 2\n config.lsp['fixture-language'] = JSON.parse({})\n config.bind 'leader d', config.core.lsp.definition\n config.bind 'leader D', config.core.lsp.diagnostics\n config.bind 'K', config.core.lsp.hover\nend\n",
serde_json::to_string(&options.to_string()).unwrap(), serde_json::to_string(&options.to_string()).unwrap(),
); );
let config_path = self.root.join("config.rb"); let config_path = self.root.join("config.rb");
@@ -457,7 +457,7 @@ impl Fixture {
json!({"extensions": ["rs"], "command": command(2, "controlled")}); json!({"extensions": ["rs"], "command": command(2, "controlled")});
} }
let config = format!( let config = format!(
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n JSON.parse({}).each {{ |language, options| config.lsp[language] = options }}\n config.bind 'K', config.core.lsp.hover\nend\n", "Vamp.configure do |config|\n config.review_directory = 'reviews'\n config.clipboard_mode = 'separate'\n JSON.parse({}).each {{ |language, options| config.lsp[language] = options }}\n config.bind 'K', config.core.lsp.hover\nend\n",
serde_json::to_string(&languages.to_string()).unwrap(), serde_json::to_string(&languages.to_string()).unwrap(),
); );
let config_path = self.root.join("config.rb"); let config_path = self.root.join("config.rb");
@@ -2357,6 +2357,50 @@ fn lsp_semantics_coalesce_changed_text_and_refresh_without_decoding_stale_payloa
&& frame.get("result").is_some())); && frame.get("result").is_some()));
} }
#[test]
fn lsp_semantic_colors_survive_edits_and_polling_until_a_current_reply() {
let fixture = Fixture::new();
let mut session = fixture.open("controlled-semantic");
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/semanticTokens/full") == 1
});
semantic_reply(
&mut session,
&fixture,
1,
json!({"result": {"data": [0, 0, 1, 4, 0]}}),
);
let original = session.editor().semantic_tokens().to_vec();
assert_eq!(original[0].kind, SemanticTokenKind::Type);
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/semanticTokens/full") == 2
});
assert_eq!(session.editor().semantic_tokens(), original);
session.handle(Input::Char('?'));
semantic_reply(
&mut session,
&fixture,
2,
json!({"result": {"data": ["stale malformed data"]}}),
);
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/semanticTokens/full") == 3
});
assert_eq!(session.editor().semantic_tokens(), original);
semantic_reply(
&mut session,
&fixture,
3,
json!({"result": {"data": [0, 1, 2, 2, 0]}}),
);
let tokens = session.editor().semantic_tokens();
assert_eq!(tokens[0].kind, SemanticTokenKind::Function);
assert_eq!(tokens[0].start, TextPosition { row: 0, column: 1 });
assert_eq!(tokens[0].end, TextPosition { row: 0, column: 2 });
}
#[test] #[test]
fn lsp_semantics_discard_queued_same_revision_rename_and_close_outputs() { fn lsp_semantics_discard_queued_same_revision_rename_and_close_outputs() {
let fixture = Fixture::new(); let fixture = Fixture::new();
+242 -2
View File
@@ -46,7 +46,7 @@ impl Fixture {
let config = self.file( let config = self.file(
"config.rb", "config.rb",
&format!( &format!(
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n{bindings}\nend\n" "Vamp.configure do |config|\n config.review_directory = 'reviews'\n config.clipboard_mode = 'separate'\n{bindings}\nend\n"
), ),
); );
let session = let session =
@@ -120,6 +120,213 @@ fn wait_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> boo
} }
} }
#[test]
fn star_activation_moves_onto_the_selected_word_only_when_outside_it() {
let fixture = Fixture::new();
for (text, column, expected) in [
(" word word", 0, 2),
(" word word", 1, 2),
(" word word", 2, 2),
(" word word", 4, 4),
("-- word word", 0, 3),
("\t界e\u{301} 界e\u{301}", 0, 1),
(" .* .*", 0, 1),
] {
let mut editor = fixture.editor(text);
keys(&mut editor, &"l".repeat(column));
keys(&mut editor, "*");
assert_eq!(
editor.cursor(),
Cursor {
row: 0,
column: expected
},
"{text:?}"
);
let first = editor.search_match().unwrap().start;
keys(&mut editor, "*");
assert!(editor.cursor().column > first.column, "{text:?}");
assert!(!editor.dirty());
}
}
#[test]
fn submitting_search_keeps_each_caret_inside_a_current_match() {
let fixture = Fixture::new();
for (text, query, column, end) in [
("jj next jj", "jj", 0, 2),
("jj next jj", "jj", 1, 2),
("界e\u{301} next 界e\u{301}", "界e\u{301}", 1, 2),
("aaaa next aa", "aa", 1, 3),
] {
let mut editor = fixture.editor(text);
keys(&mut editor, &"l".repeat(column));
let cursor = editor.cursor();
keys(&mut editor, &format!("/{query}"));
editor.handle(Input::Enter);
assert_eq!(editor.cursor(), cursor, "{text:?}");
assert_eq!(editor.search_match().unwrap().end_column, end);
keys(&mut editor, "n");
assert_ne!(editor.cursor(), cursor);
assert!(!editor.dirty());
}
let mut editor = fixture.editor("jj next jj\n-- next jj");
editor.handle(Input::Ctrl('e'));
keys(&mut editor, "/jj");
editor.handle(Input::Enter);
assert_eq!(
editor.carets(),
[Cursor::default(), Cursor { row: 1, column: 8 }]
);
}
#[test]
fn normal_escape_hides_highlights_but_retains_search_and_rearms_star() {
let fixture = Fixture::new();
let mut editor = fixture.editor("cat cat");
keys(&mut editor, "*");
assert_eq!(editor.search_matches_on_row(0).count(), 2);
editor.handle(Input::Escape);
assert_eq!(editor.search_matches_on_row(0).count(), 0);
assert_eq!(editor.search_match(), None);
assert_eq!(editor.cursor(), Cursor::default());
keys(&mut editor, "*");
assert_eq!(editor.cursor(), Cursor::default());
assert_eq!(editor.search_matches_on_row(0).count(), 2);
keys(&mut editor, "*");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 });
editor.handle(Input::Escape);
keys(&mut editor, "n");
assert_eq!(editor.cursor(), Cursor::default());
assert_eq!(editor.search_matches_on_row(0).count(), 2);
keys(&mut editor, "/missing");
editor.handle(Input::Escape);
assert_eq!(editor.search_matches_on_row(0).count(), 2);
editor.handle(Input::Escape);
editor.handle(Input::Char('/'));
editor.handle(Input::Enter);
assert_eq!(editor.cursor(), Cursor::default());
assert_eq!(editor.search_matches_on_row(0).count(), 2);
assert!(!editor.dirty());
}
#[test]
fn star_search_arms_without_moving_then_navigates_and_rearms_for_a_new_word_or_mode() {
let fixture = Fixture::new();
let mut editor = fixture.editor("cat dog cat dog");
keys(&mut editor, "l2*");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 });
assert_eq!(editor.search_match().unwrap().start, Cursor::default());
keys(&mut editor, "*");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 });
keys(&mut editor, "w*");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 12 });
keys(&mut editor, "*");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 });
keys(&mut editor, "/cat");
editor.handle(Input::Enter);
assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 });
keys(&mut editor, "*");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 8 });
keys(&mut editor, "*");
assert_eq!(editor.cursor(), Cursor::default());
}
#[test]
fn star_search_keeps_whole_word_counts_history_and_literal_search_distinct() {
let fixture = Fixture::new();
let text = "cat\nscatter\ncat_cat\ncat2\n2cat\nCat\ncat\ncat\n\n";
let mut editor = fixture.editor(text);
keys(&mut editor, "l**");
assert_eq!(editor.cursor(), Cursor { row: 6, column: 0 });
assert_eq!(editor.search_match().unwrap().end_column, 3);
keys(&mut editor, "nN2*");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 });
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.cursor(), Cursor { row: 6, column: 0 });
editor.handle(Input::Char('\t'));
assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 });
keys(&mut editor, "/scatter");
editor.handle(Input::Escape);
editor.handle(Input::Char('/'));
editor.handle(Input::Enter);
assert_eq!(editor.cursor(), Cursor::default());
keys(&mut editor, "gb*");
assert_eq!(editor.cursor(), Cursor { row: 9, column: 0 });
keys(&mut editor, "n");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 });
keys(&mut editor, "/cat");
editor.handle(Input::Enter);
keys(&mut editor, "n");
assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 });
assert_eq!(editor.search_match().unwrap().end_column, 4);
assert_eq!(editor.context().text, text);
assert!(!editor.dirty());
}
#[test]
fn star_search_uses_unicode_words_and_vim_same_line_fallbacks() {
let fixture = Fixture::new();
for (text, positioning, row, length) in [
("e\u{301}lan\ne\u{301}lanx\ne\u{301}lan", "l", 2, 4),
("世界\n世界中\n世界", "l", 2, 2),
("snake_case\nsnake_case2\nsnake_case", "3l", 2, 10),
("\t-- word\nword", "", 1, 4),
("-- word\nword", "", 1, 4),
("word --\n--", "$", 1, 2),
(" .* \n.*", "", 1, 2),
("👩‍🚀\n👩\n👩‍🚀", "", 2, 1),
("only", "l", 0, 4),
] {
let mut editor = fixture.editor(text);
keys(&mut editor, positioning);
editor.handle(Input::Char('*'));
editor.handle(Input::Char('*'));
assert_eq!(editor.cursor(), Cursor { row, column: 0 }, "{text:?}");
assert_eq!(
editor.search_match().unwrap().end_column,
length,
"{text:?}"
);
keys(&mut editor, "Nn");
assert_eq!(editor.cursor(), Cursor { row, column: 0 }, "{text:?}");
assert!(!editor.dirty());
}
}
#[test]
fn star_search_rebinding_moves_all_carets_and_remains_view_local() {
let fixture = Fixture::new();
let mut session = fixture.session(
"cat cat_cat cat cat\ncat cat_cat cat cat",
" config.bind '*', nil\n config.bind 'q', config.core.search_word",
);
session.handle(Input::Ctrl('e'));
session_keys(&mut session, "2q");
assert_eq!(
session.editor().carets(),
[Cursor::default(), Cursor { row: 1, column: 0 }]
);
session_keys(&mut session, "2q");
assert_eq!(
session.editor().carets(),
[Cursor { row: 0, column: 16 }, Cursor { row: 1, column: 16 }]
);
session_keys(&mut session, "|2N");
assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 0 });
session_keys(&mut session, "/cat");
session.handle(Input::Enter);
session_keys(&mut session, "n");
assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 4 });
session.handle(Input::Alt('h'));
session_keys(&mut session, "2N");
assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 0 });
assert!(!session.editor().dirty());
}
#[test] #[test]
fn counted_character_seeks_move_every_native_caret_independently() { fn counted_character_seeks_move_every_native_caret_independently() {
let fixture = Fixture::new(); let fixture = Fixture::new();
@@ -281,7 +488,7 @@ fn readonly_before_and_historical_after_seeks_preserve_snapshots_and_folded_exce
fs::write(&source, &working_text).unwrap(); fs::write(&source, &working_text).unwrap();
let config = fixture.file( let config = fixture.file(
"config.rb", "config.rb",
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.diff_context_lines = 1\n config.diff_expand_lines = 2\nend\n", "Vamp.configure do |config|\n config.review_directory = 'reviews'\n config.clipboard_mode = 'separate'\n config.diff_context_lines = 1\n config.diff_expand_lines = 2\nend\n",
); );
let mut session = let mut session =
EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap(); EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap();
@@ -352,6 +559,39 @@ fn readonly_before_and_historical_after_seeks_preserve_snapshots_and_folded_exce
column: second_x column: second_x
} }
); );
session_keys(&mut session, "*");
assert_eq!(
session.editor().cursor(),
Cursor {
row: 19,
column: second_x
}
);
session_keys(&mut session, "*");
assert_eq!(
session.editor().cursor(),
Cursor {
row: 20,
column: 12
}
);
assert_eq!(session.editor().search_match().unwrap().end_column, 13);
session_keys(&mut session, "3*N");
assert_eq!(
session.editor().cursor(),
Cursor {
row: 18,
column: 12
}
);
session_keys(&mut session, "3n");
assert_eq!(
session.editor().cursor(),
Cursor {
row: 19,
column: second_x
}
);
session_keys(&mut session, "Fxvfx"); session_keys(&mut session, "Fxvfx");
assert_eq!( assert_eq!(
session.editor().selections(), session.editor().selections(),
+66
View File
@@ -42,6 +42,72 @@ fn keys(editor: &mut Editor, text: &str) {
} }
} }
#[test]
fn normal_x_cuts_the_hovered_grapheme_for_paste() {
for grapheme in ["b", "e\u{301}", "👩‍🚀"] {
let fixture = Fixture::new();
let mut editor = fixture.editor(&format!("a{grapheme}z\n"));
keys(&mut editor, "lx");
assert_eq!(editor.context().text, "az\n");
assert_eq!(editor.mode(), Mode::Normal);
keys(&mut editor, "P");
assert_eq!(editor.context().text, format!("a{grapheme}z\n"));
}
}
#[test]
fn normal_delete_and_remapped_action_copy_characterwise() {
for input in [Input::Delete, Input::Char('q')] {
let fixture = Fixture::new();
let mut editor = fixture.editor("abz\n");
editor
.bind("normal", "q", Some("core.delete".into()))
.unwrap();
keys(&mut editor, "yyl");
editor.handle(input);
assert_eq!(editor.context().text, "az\n");
keys(&mut editor, "uUP");
assert_eq!(editor.context().text, "abz\n");
}
}
#[test]
fn normal_x_repeat_copies_current_graphemes_and_preserves_empty_line_clipboard() {
let fixture = Fixture::new();
let mut editor = fixture.editor("abc\n\nxyz\n");
keys(&mut editor, "x.");
assert_eq!(editor.context().text, "c\n\nxyz\n");
keys(&mut editor, "jxjP");
assert_eq!(editor.context().text, "c\n\nbxyz\n");
}
#[test]
fn normal_x_cuts_at_every_caret_without_crossing_empty_lines() {
let fixture = Fixture::new();
let source = "a👩‍🚀z\n\nbe\u{301}y\n";
let mut editor = fixture.editor(source);
keys(&mut editor, "l");
editor.handle(Input::Ctrl('e'));
editor.handle(Input::Ctrl('e'));
keys(&mut editor, "x");
assert_eq!(editor.context().text, "az\n\nby\n");
keys(&mut editor, "P");
assert_eq!(editor.context().text, source);
}
#[test]
fn insert_delete_and_its_repeat_do_not_overwrite_clipboard() {
let fixture = Fixture::new();
let mut editor = fixture.editor("abc\nxyz\n");
keys(&mut editor, "vyi");
editor.handle(Input::Delete);
editor.handle(Input::Escape);
keys(&mut editor, "j.");
assert_eq!(editor.context().text, "bc\nyz\n");
keys(&mut editor, "P");
assert_eq!(editor.context().text, "bc\nayz\n");
}
#[test] #[test]
fn visual_r_replaces_selected_graphemes_as_one_undo_edit() { fn visual_r_replaces_selected_graphemes_as_one_undo_edit() {
let fixture = Fixture::new(); let fixture = Fixture::new();
+56
View File
@@ -42,6 +42,62 @@ fn keys(editor: &mut Editor, text: &str) {
} }
} }
#[test]
fn visual_paste_replaces_selection_and_preserves_clipboard_and_undo() {
for paste in ['p', 'P'] {
for (yank, select, expected) in [
("vly", "j0vl", "XY\nXYc\nend"),
("vly", "j0lvh", "XY\nXYc\nend"),
("yy", "j0vl", "XY\nXY\nc\nend"),
("yy", "jV", "XY\nXY\nend"),
("vly", "jV", "XY\nXYend"),
] {
let fixture = Fixture::new();
let source = "XY\nabc\nend";
let mut editor = fixture.editor(source);
keys(&mut editor, yank);
keys(&mut editor, select);
editor.handle(Input::Char(paste));
assert_eq!(editor.context().text, expected, "{yank} {select} {paste}");
assert_eq!(editor.mode(), Mode::Normal);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
keys(&mut editor, "U");
assert_eq!(editor.context().text, expected);
keys(
&mut editor,
if select.ends_with("vh") {
"u0l."
} else {
"u0."
},
);
assert_eq!(
editor.context().text,
expected,
"repeat: {yank} {select} {paste}"
);
keys(&mut editor, "u");
keys(&mut editor, select.trim_start_matches('j'));
editor.handle(Input::Char(paste));
assert_eq!(editor.context().text, expected, "clipboard stays unchanged");
}
}
}
#[test]
fn visual_paste_replaces_every_selected_range_in_one_undo_group() {
let fixture = Fixture::new();
let source = "XY\n界e\u{301}\n👩‍🚀z";
let mut editor = fixture.editor(source);
keys(&mut editor, "vlyj0vl");
editor.handle(Input::Ctrl('e'));
keys(&mut editor, "p");
assert_eq!(editor.context().text, "XY\nXY\nXY");
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
}
#[test] #[test]
fn whole_line_yank_pastes_above_or_below_the_target_and_keeps_indentation() { fn whole_line_yank_pastes_above_or_below_the_target_and_keeps_indentation() {
let source = " \t界e\u{301}👩‍🚀\nother\nlast\n"; let source = " \t界e\u{301}👩‍🚀\nother\nlast\n";
+1113 -15
View File
File diff suppressed because it is too large Load Diff
+275
View File
@@ -0,0 +1,275 @@
#![cfg(unix)]
use std::{
fs,
path::PathBuf,
process::Command,
sync::atomic::{AtomicU64, Ordering},
thread,
time::{Duration, Instant},
};
use vamp::{
editor::{Input, ResolverStatus},
session::EditorSession,
};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Fixture {
root: PathBuf,
base: String,
}
impl Fixture {
fn jj(&self, args: &[&str]) -> String {
let result = Command::new("jj")
.args(["--no-pager", "--color=never"])
.args(args)
.current_dir(&self.root)
.env("JJ_CONFIG", "/dev/null")
.env("JJ_USER", "Resolver Test")
.env("JJ_EMAIL", "resolver@example.invalid")
.env("JJ_TIMESTAMP", "2000-01-01T00:00:00Z")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
String::from_utf8(result.stdout).unwrap().trim().into()
}
fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"vamp-resolve-session-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&root).unwrap();
let mut fixture = Self {
root,
base: String::new(),
};
fixture.jj(&["git", "init", "--no-colocate", "."]);
for name in ["first.txt", "second.txt"] {
fs::write(fixture.root.join(name), Self::text("base")).unwrap();
}
fixture.jj(&["describe", "-m", "base"]);
fixture.base = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
fixture.jj(&["new", &fixture.base]);
for name in ["first.txt", "second.txt"] {
fs::write(fixture.root.join(name), Self::text("left")).unwrap();
}
fixture.jj(&["describe", "-m", "left"]);
let left = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
fixture.jj(&["new", &fixture.base]);
for name in ["first.txt", "second.txt"] {
fs::write(fixture.root.join(name), Self::text("right")).unwrap();
}
fixture.jj(&["describe", "-m", "right"]);
let right = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
fixture.jj(&["new", &left, &right, "-m", "conflicted result"]);
fixture
}
fn text(side: &str) -> String {
format!(
"{side} first\n{}{side} last\n",
(0..30)
.map(|n| format!("context_{n}\n"))
.collect::<String>()
)
}
fn open(&self) -> EditorSession {
let config = self.root.join("test-config.rb");
fs::write(
&config,
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\nend\n",
)
.unwrap();
EditorSession::open(
Some(self.root.join("first.txt")),
vec!["text".into()],
Some(&config),
)
.unwrap()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn ready(session: &mut EditorSession) {
let deadline = Instant::now() + Duration::from_secs(15);
loop {
session.poll();
if session.editor().resolver().is_some_and(|resolver| {
!resolver.loading
&& resolver
.files
.iter()
.all(|file| file.status != ResolverStatus::Checking)
}) {
return;
}
assert!(
Instant::now() < deadline,
"resolver did not settle: {}",
session.editor().message()
);
thread::sleep(Duration::from_millis(1));
}
}
#[test]
fn resolver_keeps_unsaved_result_and_undo_then_saves_and_retains_resolved_file() {
let fixture = Fixture::new();
let original_disk = fs::read_to_string(fixture.root.join("first.txt")).unwrap();
let mut session = fixture.open();
for input in [
Input::Char('i'),
Input::Paste("draft\n".into()),
Input::Escape,
] {
session.handle(input);
}
let draft = session.editor().context().text;
let operation = fixture.jj(&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"--limit",
"1",
"-T",
"id",
]);
session.execute("resolve");
ready(&mut session);
let seeded = format!("draft\n{}", Fixture::text("left"));
assert_eq!(session.editor().context().text, seeded);
assert_ne!(seeded, draft);
assert!(session.editor().dirty());
assert_eq!(
fs::read_to_string(fixture.root.join("first.txt")).unwrap(),
original_disk
);
assert_eq!(
fixture.jj(&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"--limit",
"1",
"-T",
"id"
]),
operation
);
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, draft);
session.handle(Input::Char('U'));
assert_eq!(session.editor().context().text, seeded);
let source = session.editor().resolver().unwrap().files[0]
.sources
.iter()
.enumerate()
.filter(|(_, source)| !source.base)
.nth(1)
.unwrap()
.0
+ 1;
session.execute(&format!("resolve.use_source {source}"));
let chosen = session.editor().context().text;
assert_ne!(chosen, seeded);
assert!(chosen.starts_with("draft\n"));
assert!(chosen.contains("context_15\n"));
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, seeded);
session.execute(&format!("resolve.use_source {source}"));
let cursor = session.editor().cursor();
let resolved_text = session.editor().context().text;
session.execute("resolve.view.toggle");
session.execute("resolve.view.toggle");
assert_eq!(session.editor().context().text, resolved_text);
assert_eq!(session.editor().cursor(), cursor);
session.execute("w");
ready(&mut session);
let resolver = session.editor().resolver().unwrap();
assert_eq!(resolver.files.len(), 2);
assert_eq!(
resolver.files[resolver.current_file].status,
ResolverStatus::Resolved
);
assert_eq!(
fs::read_to_string(fixture.root.join("first.txt")).unwrap(),
resolved_text
);
assert!(
!fixture
.jj(&["--ignore-working-copy", "resolve", "--list"])
.contains("first.txt")
);
assert!(
fixture
.jj(&["--ignore-working-copy", "resolve", "--list"])
.contains("second.txt")
);
}
#[test]
fn resolver_refuses_save_after_checkout_change_even_when_forced_and_preserves_draft() {
let fixture = Fixture::new();
let mut session = fixture.open();
session.execute("resolve");
ready(&mut session);
for input in [
Input::Char('i'),
Input::Paste("keep my draft\n".into()),
Input::Escape,
] {
session.handle(input);
}
let draft = session.editor().context().text;
fixture.jj(&["new", &fixture.base]);
let checkout_disk = fs::read_to_string(fixture.root.join("first.txt")).unwrap();
for save in ["w", "w!"] {
session.execute(save);
ready(&mut session);
assert_eq!(
fs::read_to_string(fixture.root.join("first.txt")).unwrap(),
checkout_disk
);
assert_eq!(session.editor().context().text, draft);
assert!(session.editor().dirty());
}
session.execute("resolve.close");
assert_eq!(session.editor().context().text, draft);
assert!(session.editor().dirty());
}
#[test]
fn resolver_save_preserves_external_writes_and_keeps_the_unsaved_result() {
let fixture = Fixture::new();
let mut session = fixture.open();
session.execute("resolve");
ready(&mut session);
for input in [
Input::Char('i'),
Input::Paste("local draft\n".into()),
Input::Escape,
] {
session.handle(input);
}
let draft = session.editor().context().text;
fs::write(fixture.root.join("first.txt"), "external edit\n").unwrap();
session.execute("w");
ready(&mut session);
assert_eq!(
fs::read_to_string(fixture.root.join("first.txt")).unwrap(),
"external edit\n"
);
assert_eq!(session.editor().context().text, draft);
assert!(session.editor().dirty());
}
+130
View File
@@ -0,0 +1,130 @@
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);
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());
}
+197
View File
@@ -0,0 +1,197 @@
use super::*;
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicBool, AtomicU64, Ordering},
};
#[test]
fn percentages_reject_invalid_data_and_include_both_endpoints() {
assert_eq!(percentage(0, 100), Some(0));
assert_eq!(percentage(100, 100), Some(100));
assert_eq!(percentage(1, 3), Some(33));
assert_eq!(percentage(u64::MAX, u64::MAX), Some(100));
assert_eq!(percentage(0, 0), None);
assert_eq!(percentage(101, 100), None);
assert_eq!(windows_charge(0, 4), Some(0));
assert_eq!(windows_charge(100, 8), Some(100));
assert_eq!(windows_charge(101, 0), None);
assert_eq!(windows_charge(255, 0), None);
assert_eq!(windows_charge(50, 128), None);
assert_eq!(windows_charge(50, 255), None);
}
fn worker(samples: Vec<Option<u8>>) -> (Battery, Receiver<()>, Arc<AtomicBool>) {
struct ReaderDropped(Arc<AtomicBool>);
impl Drop for ReaderDropped {
fn drop(&mut self) {
self.0.store(true, Ordering::Release);
}
}
let dropped = Arc::new(AtomicBool::new(false));
let reader = ReaderDropped(Arc::clone(&dropped));
let mut samples = samples.into_iter();
let (ready, published) = mpsc::channel();
let battery = Battery::start_with(
move || {
let _ = &reader;
samples
.next()
.expect("test provides every requested sample")
},
move |wake| {
ready.send(()).unwrap();
// Explicit channel messages replace the production clock entirely.
wake.recv().is_ok()
},
)
.unwrap();
(battery, published, dropped)
}
#[test]
fn worker_reads_immediately_replaces_stale_samples_and_clears_invalid_data() {
let (mut battery, published, dropped) = worker(vec![Some(0), Some(100), None, Some(101)]);
published.recv().unwrap();
assert_eq!(battery.poll(), Some(Some(0)));
assert_eq!(battery.poll(), None);
battery.wake.as_ref().unwrap().send(()).unwrap();
published.recv().unwrap();
battery.wake.as_ref().unwrap().send(()).unwrap();
published.recv().unwrap();
assert_eq!(battery.poll(), Some(None));
assert_eq!(battery.poll(), None);
battery.wake.as_ref().unwrap().send(()).unwrap();
published.recv().unwrap();
assert_eq!(battery.poll(), Some(None));
drop(battery);
assert!(dropped.load(Ordering::Acquire));
}
#[test]
fn dropping_interrupts_wait_and_joins_even_with_an_unread_sample() {
let (battery, published, dropped) = worker(vec![Some(50)]);
published.recv().unwrap();
drop(battery);
assert!(dropped.load(Ordering::Acquire));
}
static FIXTURE_ID: AtomicU64 = AtomicU64::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"vamp-battery-{}-{}",
std::process::id(),
FIXTURE_ID.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&path).unwrap();
Self(path)
}
fn source(&self, name: &str, fields: &[(&str, &str)]) {
let path = self.0.join(name);
fs::create_dir_all(&path).unwrap();
for (key, value) in fields {
fs::write(path.join(key), value).unwrap();
}
}
fn read(&self) -> Option<u8> {
linux::read_charge(&self.0)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).unwrap();
}
}
#[test]
fn sysfs_reads_present_system_batteries_but_not_external_or_absent_sources() {
let fixture = Fixture::new();
assert_eq!(fixture.read(), None);
fixture.source("AC", &[("type", "Mains"), ("capacity", "100")]);
fixture.source(
"mouse",
&[
("type", "Battery"),
("scope", "Device"),
("present", "1"),
("capacity", "100"),
],
);
fixture.source(
"absent",
&[("type", "Battery"), ("present", "0"), ("capacity", "100")],
);
assert_eq!(fixture.read(), None);
fixture.source(
"BAT0",
&[
("type", "Battery\n"),
("scope", "System\n"),
("present", "1\n"),
("capacity", "27\n"),
],
);
assert_eq!(fixture.read(), Some(27));
fixture.source("BAT0", &[("capacity", "0")]);
assert_eq!(fixture.read(), Some(0));
fixture.source("BAT0", &[("capacity", "100")]);
assert_eq!(fixture.read(), Some(100));
for invalid in ["101", "-1", "255", "garbage", ""] {
fixture.source("BAT0", &[("capacity", invalid)]);
assert_eq!(fixture.read(), None);
}
fs::remove_file(fixture.0.join("BAT0/capacity")).unwrap();
assert_eq!(fixture.read(), None);
}
#[test]
fn sysfs_uses_matching_actual_energy_or_charge_totals_only() {
let energy = Fixture::new();
energy.source(
"BAT0",
&[
("type", "Battery"),
("present", "1"),
("energy_now", "27"),
("energy_full", "100"),
],
);
assert_eq!(energy.read(), Some(27));
energy.source("BAT0", &[("energy_full", "0")]);
assert_eq!(energy.read(), None);
energy.source("BAT0", &[("energy_full", "26")]);
assert_eq!(energy.read(), None);
energy.source("BAT0", &[("energy_full", "garbage")]);
assert_eq!(energy.read(), None);
let charge = Fixture::new();
charge.source(
"BAT0",
&[
("type", "Battery"),
("present", "1"),
("charge_now", "15"),
("charge_full", "20"),
],
);
assert_eq!(charge.read(), Some(75));
charge.source("BAT0", &[("energy_now", "5")]);
assert_eq!(charge.read(), None);
let design = Fixture::new();
design.source(
"BAT0",
&[
("type", "Battery"),
("present", "1"),
("energy_now", "20"),
("energy_full_design", "100"),
],
);
assert_eq!(design.read(), None);
}
+32
View File
@@ -0,0 +1,32 @@
unsafe extern "C" {
fn CFNumberCreate(allocator: Object, kind: isize, value: Object) -> Object;
}
use super::*;
#[test]
fn native_values_are_type_checked_and_numbers_must_be_exact_and_nonnegative() {
assert!(Owned::new(std::ptr::null()).is_none());
unsafe {
assert_eq!(number(std::ptr::null()), None);
let text = Owned::string(c"27").unwrap();
assert_eq!(number(text.0), None);
for value in [-1_i64, 0, 100] {
let object = Owned::new(CFNumberCreate(
std::ptr::null(),
4,
(&value as *const i64).cast(),
))
.unwrap();
assert_eq!(number(object.0), u64::try_from(value).ok());
}
let fraction = 1.5_f64;
let object = Owned::new(CFNumberCreate(
std::ptr::null(),
6,
(&fraction as *const f64).cast(),
))
.unwrap();
assert_eq!(number(object.0), None);
}
}
+621
View File
@@ -0,0 +1,621 @@
use super::*;
fn command(root: &Path, program: &str, args: &[&str]) -> String {
let mut command = Command::new(program);
if program == "jj" {
command.args([
"--config=user.name=\"Jj Author\"",
"--config=user.email=\"jj@example.test\"",
]);
command.args([
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
]);
}
let output = command
.args(args)
.current_dir(root)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
.env("JJ_CONFIG", "/dev/null")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
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) {
let temp = Temporary::new().unwrap();
command(&temp.0, "git", &["init", "--quiet", "--template="]);
command(&temp.0, "git", &["config", "user.name", "Blame Author"]);
command(
&temp.0,
"git",
&["config", "user.email", "blame@example.test"],
);
let path = temp.0.join("-literal [file].txt");
fs::write(&path, "first\nsecond\n").unwrap();
command(&temp.0, "git", &["add", "--", "-literal [file].txt"]);
command(
&temp.0,
"git",
&["commit", "--quiet", "-m", "Original summary"],
);
let commit = command(&temp.0, "git", &["rev-parse", "HEAD"]);
(temp, path, commit)
}
#[test]
fn committed_line_has_native_author_summary_and_commit() {
let (_temp, path, commit) = fixture();
let rows = read_file(&path, "first\nsecond\n", &|| false)
.unwrap()
.unwrap();
assert_eq!(rows.len(), 2);
assert!(Arc::ptr_eq(
rows[0].as_ref().unwrap(),
rows[1].as_ref().unwrap()
));
assert_eq!(
**rows[1].as_ref().unwrap(),
LineBlame {
commit,
author: "Blame Author".into(),
summary: "Original summary".into(),
uncommitted: false
}
);
}
#[test]
fn unsaved_changed_line_is_uncommitted_without_metadata() {
let (_temp, path, _) = fixture();
let rows = read_file(&path, "changed\nsecond\n", &|| false)
.unwrap()
.unwrap();
assert_eq!(
**rows[0].as_ref().unwrap(),
LineBlame {
commit: String::new(),
author: String::new(),
summary: String::new(),
uncommitted: true
}
);
assert_eq!(fs::read_to_string(path).unwrap(), "first\nsecond\n");
}
#[test]
fn inserted_line_preserves_following_original_attribution() {
let (_temp, path, commit) = fixture();
let contents = "inserted\nfirst\nsecond\n";
let rows = read_file(&path, contents, &|| false).unwrap().unwrap();
assert!(rows[0].as_ref().unwrap().uncommitted);
assert_eq!(rows[2].as_ref().unwrap().commit, commit);
assert_eq!(rows.len(), 3);
assert_eq!(read_file(&path, "", &|| false).unwrap(), None);
}
#[test]
fn unsaved_insertions_and_deletions_keep_shifted_commit_attribution() {
let (temp, path, original) = fixture();
fs::write(&path, "first\nmiddle\nsecond\ntail\n").unwrap();
command(&temp.0, "git", &["add", "--", "-literal [file].txt"]);
command(&temp.0, "git", &["commit", "--quiet", "-m", "Added lines"]);
let added = command(&temp.0, "git", &["rev-parse", "HEAD"]);
let rows = read_file(
&path,
"unsaved\nfirst\nsecond\nunsaved tail\ntail\n",
&|| false,
)
.unwrap()
.unwrap();
assert!(rows[0].as_ref().unwrap().uncommitted);
assert_eq!(rows[1].as_ref().unwrap().commit, original);
assert_eq!(rows[2].as_ref().unwrap().commit, original);
assert!(Arc::ptr_eq(
rows[1].as_ref().unwrap(),
rows[2].as_ref().unwrap()
));
assert!(rows[3].as_ref().unwrap().uncommitted);
assert!(Arc::ptr_eq(
rows[0].as_ref().unwrap(),
rows[3].as_ref().unwrap()
));
assert_eq!(rows[4].as_ref().unwrap().commit, added);
assert_eq!(rows[4].as_ref().unwrap().summary, "Added lines");
assert_eq!(
fs::read_to_string(path).unwrap(),
"first\nmiddle\nsecond\ntail\n"
);
}
#[test]
fn missing_untracked_unborn_nonrepo_and_symlink_have_no_blame() {
let temp = Temporary::new().unwrap();
let path = temp.0.join("file");
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
fs::write(&path, "text").unwrap();
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
command(&temp.0, "git", &["init", "--quiet", "--template="]);
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
let (_committed, tracked, _) = fixture();
let untracked = tracked.parent().unwrap().join("untracked");
fs::write(&untracked, "text").unwrap();
assert_eq!(read_file(&untracked, "text", &|| false).unwrap(), None);
let link = temp.0.join("link");
std::os::unix::fs::symlink(tracked, &link).unwrap();
assert_eq!(
read_file(&link, "first\nsecond\n", &|| false).unwrap(),
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]
fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() {
let temp = Temporary::new().unwrap();
command(&temp.0, "jj", &["git", "init", "--no-colocate", "."]);
let path = temp.0.join("file");
fs::write(&path, "original\n").unwrap();
assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None);
command(
&temp.0,
"jj",
&[
"--config=user.name=\"Jj Author\"",
"--config=user.email=\"jj@example.test\"",
"describe",
"-m",
"Jj summary",
],
);
command(
&temp.0,
"jj",
&[
"--config=user.name=\"Jj Author\"",
"--config=user.email=\"jj@example.test\"",
"new",
],
);
let parent = command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"log",
"--no-graph",
"-r",
"@-",
"-T",
"commit_id",
],
);
let operation = command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id",
],
);
let rows = read_file(&path, "original\n", &|| false).unwrap().unwrap();
let line = rows[0].as_ref().unwrap();
assert_eq!(line.commit, parent);
assert_eq!(line.author, "Jj Author");
assert_eq!(line.summary, "Jj summary");
fs::write(&path, "dirty on disk\n").unwrap();
let shifted = read_file(&path, "inserted\noriginal\n", &|| false)
.unwrap()
.unwrap();
assert!(shifted[0].as_ref().unwrap().uncommitted);
assert_eq!(shifted[1].as_ref().unwrap().commit, parent);
assert!(
read_file(&path, "unsaved\n", &|| false).unwrap().unwrap()[0]
.as_ref()
.unwrap()
.uncommitted
);
assert_eq!(
command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id"
]
),
operation
);
assert!(!temp.0.join(".git").exists());
command(&temp.0, "jj", &["new", "root()"]);
fs::write(temp.0.join("other"), "sibling\n").unwrap();
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
command(&temp.0, "jj", &["new", &parent, "@"]);
assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None);
}
#[test]
fn incremental_groups_share_metadata_across_ranges_and_repeated_commits() {
let first = "1111111111111111111111111111111111111111";
let second = "2222222222222222222222222222222222222222";
let output = format!(
"{first} 5 3 2\nauthor First\nsummary First summary\nfilename file\n\
{second} 8 2 1\nauthor Second\nsummary Second summary\nfilename file\n\
{first} 1 1 1\nfilename old-file\n"
);
let rows = parse(output.as_bytes(), 4).unwrap();
assert_eq!(rows[0].as_ref().unwrap().commit, first);
assert_eq!(rows[1].as_ref().unwrap().author, "Second");
assert_eq!(rows[1].as_ref().unwrap().commit, second);
assert!(Arc::ptr_eq(
rows[0].as_ref().unwrap(),
rows[2].as_ref().unwrap()
));
assert!(Arc::ptr_eq(
rows[2].as_ref().unwrap(),
rows[3].as_ref().unwrap()
));
}
#[test]
fn malformed_incremental_groups_never_echo_metadata() {
let commit = "1111111111111111111111111111111111111111";
let metadata = "author secret\nsummary secret\nfilename secret\n";
let valid = format!("{commit} 1 1 1\n{metadata}");
for output in [
"secret metadata".into(),
"\u{fffd}secret metadata".into(),
format!("{commit} 1 2 1\n{metadata}"),
format!("{commit} 1 1 2\n{metadata}"),
format!("{commit} 0 1 1\n{metadata}"),
format!("{commit} 1 1 0\n{metadata}"),
format!("{commit} 1 1 1 extra\n{metadata}"),
format!("{commit} 1 1 1\nauthor secret\nfilename secret\n"),
format!("{commit} 1 1 1\nauthor secret\nauthor secret\nsummary secret\nfilename secret\n"),
format!("{commit} 1 1 1\nauthor secret\nsummary secret\n\tsecret source\n"),
format!("{valid}{commit} 2 1 1\nfilename secret\n"),
format!("{commit} 1 1 1\nauthor secret\nsummary secret\n{commit} 2 1 1\nfilename secret\n"),
format!("{commit} {} 1 2\n{metadata}", usize::MAX),
] {
assert_eq!(
parse(output.as_bytes(), 1).unwrap_err(),
"Invalid Git blame metadata"
);
}
assert_eq!(
parse(valid.as_bytes(), 2).unwrap_err(),
"Invalid Git blame metadata"
);
assert_eq!(
parse(b"\xffsecret metadata", 1).unwrap_err(),
"Invalid Git blame metadata"
);
}
#[test]
fn cancelled_commands_kill_and_reap_pipe_holding_children() {
let temp = Temporary::new().unwrap();
let ready = temp.0.join("ready");
let cancellation = Arc::new(AtomicBool::new(false));
let stop = Arc::clone(&cancellation);
let gate = ready.clone();
let worker = thread::spawn(move || {
let mut command = Command::new("sh");
command
.args([
"-c",
"sleep 600 & printf ready > \"$1\"; wait",
"vamp-blame-test",
])
.arg(gate);
run(command, &[], &|| stop.load(Ordering::Acquire))
});
while !ready.exists() && !worker.is_finished() {
thread::yield_now();
}
assert!(ready.exists());
cancellation.store(true, Ordering::Release);
assert_eq!(worker.join().unwrap().unwrap_err(), "Blame cancelled");
}
#[test]
fn worker_keeps_only_latest_request_and_bounds_input() {
let (_temp, path, commit) = fixture();
let mut worker = BlameWorker::start().unwrap();
for id in 0..20 {
worker
.request(id, path.clone(), "first\nsecond\n".into(), None)
.unwrap();
}
loop {
if let Some((id, result)) = worker.poll() {
assert_eq!(id, 19);
let rows = result.unwrap().unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[1].as_ref().unwrap().commit, commit);
assert!(Arc::ptr_eq(
rows[0].as_ref().unwrap(),
rows[1].as_ref().unwrap()
));
break;
}
thread::yield_now();
}
assert_eq!(
worker
.request(20, path, "x".repeat(LIMIT + 1), None)
.unwrap_err()
.kind(),
io::ErrorKind::InvalidInput
);
}
#[test]
fn subprocess_output_is_bounded() {
let mut command = Command::new("sh");
command.args(["-c", "dd if=/dev/zero bs=1048576 count=17 2>/dev/null"]);
assert_eq!(
run(command, &[], &|| false).unwrap_err(),
"Blame output exceeds 16 MiB"
);
}
+184
View File
@@ -0,0 +1,184 @@
use super::*;
#[test]
fn popup_partial_options_keep_command_and_search_independent() {
let popup: PopupConfig = serde_json::from_value(serde_json::json!({
"command": {"anchor": "top_right"}
}))
.unwrap();
assert_eq!(popup.command.anchor, PopupAnchor::TopRight);
assert_eq!(popup.search.anchor, PopupAnchor::BottomLeft);
let popup: PopupConfig = serde_json::from_value(serde_json::json!({
"command": {}, "search": {"anchor": "center_left"}
}))
.unwrap();
assert_eq!(popup.command.anchor, PopupAnchor::BottomLeft);
assert_eq!(popup.search.anchor, PopupAnchor::CenterLeft);
}
#[test]
fn malformed_popup_options_are_rejected_at_the_boundary() {
for value in [
serde_json::json!(null),
serde_json::json!([]),
serde_json::json!(7),
serde_json::json!({"selector": {}}),
] {
assert!(
serde_json::from_value::<PopupConfig>(value.clone()).is_err(),
"{value}"
);
}
for options in [
serde_json::json!(null),
serde_json::json!([]),
serde_json::json!("center"),
serde_json::json!({"anchor": null}),
serde_json::json!({"anchor": 7}),
serde_json::json!({"anchor": true}),
serde_json::json!({"anchor": []}),
serde_json::json!({"anchor": {}}),
serde_json::json!({"anchor": "middle"}),
serde_json::json!({"anchor": "TopLeft"}),
serde_json::json!({"width": 72}),
serde_json::json!({"anchor": "center", "callback": "run"}),
] {
for mode in ["command", "search"] {
let mut popup = serde_json::json!({});
popup[mode] = options.clone();
assert!(
serde_json::from_value::<PopupConfig>(popup).is_err(),
"{mode}: {options}"
);
}
}
}
#[derive(Deserialize)]
struct Lines {
#[serde(deserialize_with = "top")]
top: ChromeLine,
#[serde(deserialize_with = "bottom")]
bottom: ChromeLine,
}
#[test]
fn omitted_options_inherit_and_explicit_groups_replace_defaults() {
let lines: Lines = serde_json::from_value(serde_json::json!({
"top": {"right": ["battery"], "tabs": {"title": "T{index}:{path}{modified}"}, "battery": {"padding": 0}},
"bottom": {"left": [], "center": ["workspace"]}
})).unwrap();
assert_eq!(lines.top.left, ["tabs"]);
assert_eq!(lines.top.segment("battery").padding, 0);
assert!(lines.bottom.left.is_empty());
assert_eq!(lines.bottom.right, ["language", "lsp", "position"]);
assert_eq!(lines.bottom.segment("workspace"), &SegmentConfig::default());
}
#[test]
fn both_positions_accept_all_builtins_and_custom_styles() {
let defaults = ChromeLine::top();
assert_eq!(defaults.right, ["diff", "shell", "battery"]);
let names = [
"tabs",
"mode",
"pending",
"buffer",
"diagnostics",
"path",
"selections",
"count",
"language",
"lsp",
"position",
"battery",
"diff",
"workspace",
];
let options = serde_json::json!({
"left": names,
"center": ["language"],
"right": ["tabs"],
"tabs": {"title": "{index}:{name}:{path}{modified}", "padding": 0},
"mode": {"leading_edge": "<", "trailing_edge": ">"},
"workspace": {"first_leading_edge": ""}
});
let lines: Lines = serde_json::from_value(serde_json::json!({
"top": options,
"bottom": options,
}))
.unwrap();
for line in [&lines.top, &lines.bottom] {
assert_eq!(line.left, names);
assert_eq!(line.center, ["language"]);
assert_eq!(line.right, ["tabs"]);
assert_eq!(line.segment("tabs").padding, 0);
assert_eq!(line.segment("mode").leading_edge, "<");
assert_eq!(
line.segment("workspace").first_leading_edge.as_deref(),
Some("")
);
}
}
#[test]
fn comparison_placeholders_are_valid_in_every_segment_and_bar() {
for name in [
"tabs",
"mode",
"pending",
"buffer",
"diagnostics",
"path",
"selections",
"count",
"language",
"lsp",
"position",
"battery",
"diff",
"workspace",
] {
let options = serde_json::json!({
"left": [name],
(name): {"title": "{index}:{view}{name}{changes}:{path}{modified}"}
});
let lines: Lines = serde_json::from_value(serde_json::json!({
"top": options,
"bottom": options,
}))
.unwrap();
for line in [&lines.top, &lines.bottom] {
assert_eq!(
line.segment(name).title,
"{index}:{view}{name}{changes}:{path}{modified}"
);
}
}
}
#[test]
fn malformed_chrome_is_rejected_at_the_boundary() {
for options in [
serde_json::json!({"left": ["bad-name"]}),
serde_json::json!({"left": null}),
serde_json::json!({"tabs": {"padding": 65}}),
serde_json::json!({"tabs": {"padding": null}}),
serde_json::json!({"tabs": {"margin": 65}}),
serde_json::json!({"tabs": {"margin": -1}}),
serde_json::json!({"tabs": {"margin": null}}),
serde_json::json!({"tabs": {"margin": "1"}}),
serde_json::json!({"tabs": {"margin": 1.5}}),
serde_json::json!({"tabs": {"leading_edge": "\n"}}),
serde_json::json!({"tabs": {"title": "{unknown}"}}),
serde_json::json!({"tabs": {"title": "{name"}}),
serde_json::json!({"tabs": {"title": "name}"}}),
serde_json::json!({"tabs": {"colour": "red"}}),
] {
for position in ["top", "bottom"] {
let mut frame = serde_json::json!({"top": {}, "bottom": {}});
frame[position] = options.clone();
assert!(serde_json::from_value::<Lines>(frame).is_err());
}
}
}
+72
View File
@@ -0,0 +1,72 @@
use super::*;
fn parse(args: &[&str]) -> Result<Option<Options>, String> {
parse_options(args.iter().map(std::ffi::OsString::from))
}
#[test]
fn review_commands_preserve_filenames_and_validate_arguments() {
assert_eq!(
parse(&["review"]).unwrap().unwrap().file,
Some("review".into())
);
assert_eq!(
parse(&["--", "--review"]).unwrap().unwrap().file,
Some("--review".into())
);
assert!(
parse(&["--no-config", "--review", "list", "--repo", ".", "--json"])
.unwrap()
.unwrap()
.review
.is_some()
);
assert!(
parse(&["--review", "show", "1", "--all", "--no-config"])
.unwrap()
.unwrap()
.review
.is_some()
);
assert!(
parse(&[
"--review",
"delete",
"1",
"2",
"--repo",
".",
"--json",
"--no-config"
])
.unwrap()
.unwrap()
.review
.is_some()
);
for args in [
vec!["--review"],
vec!["--review", "show"],
vec!["--review", "show", "0"],
vec!["--review", "show", "-1"],
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", "--repo"],
vec!["--review", "list", "--repo", ".", "--repo", "."],
vec!["--review", "list", "extra"],
vec!["--review", "list", "--", "extra"],
vec!["--review", "list", "--config", "x", "--no-config"],
] {
assert!(parse(&args).is_err(), "{args:?}");
}
}
+192
View File
@@ -0,0 +1,192 @@
use super::*;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::sync::atomic::{AtomicU64, Ordering};
static FIXTURE_ID: AtomicU64 = AtomicU64::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"vamp-clipboard-{}-{}",
std::process::id(),
FIXTURE_ID.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&path).unwrap();
Self(path)
}
fn command(&self, name: &str, body: &str) -> PathBuf {
let path = self.0.join(name);
fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
path
}
fn clipboard(&self, text: &[u8]) -> SystemClipboard {
let state = self.0.join("state");
fs::write(&state, text).unwrap();
let state = format!("'{}'", state.to_str().unwrap().replace('\'', "'\\''"));
SystemClipboard::with_commands(
self.command("read", &format!("exec /bin/cat {state}")),
self.command("write", &format!("exec /bin/cat > {state}")),
)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).unwrap();
}
}
#[test]
fn construction_does_not_probe_or_spawn() {
assert!(SystemClipboard::new().commands.is_none());
}
#[test]
fn round_trips_unicode_whitespace_and_large_pipes_exactly() {
let fixture = Fixture::new();
let mut clipboard = fixture.clipboard(b"");
for text in ["", " héllo\t世界🙂\r\n\n", &"é\n".repeat(300_000)] {
clipboard.write(text).unwrap();
assert_eq!(fs::read(fixture.0.join("state")).unwrap(), text.as_bytes());
assert_eq!(clipboard.read().unwrap(), text);
}
}
#[test]
fn rejects_oversize_and_invalid_utf8_without_leaking_contents() {
let fixture = Fixture::new();
let mut clipboard = fixture.clipboard(&[0xff, b'\n']);
assert_eq!(
clipboard.read().unwrap_err(),
"System clipboard is not valid UTF-8"
);
let maximum = "é".repeat(MAX_CLIPBOARD_BYTES / 2);
clipboard.write(&maximum).unwrap();
assert_eq!(clipboard.read().unwrap(), maximum);
let too_large = format!("{maximum}x");
assert_eq!(
clipboard.write(&too_large).unwrap_err(),
"System clipboard text exceeds 4 MiB"
);
assert_eq!(
fs::read(fixture.0.join("state")).unwrap(),
maximum.as_bytes()
);
fs::write(fixture.0.join("state"), &too_large).unwrap();
assert_eq!(
clipboard.read().unwrap_err(),
"System clipboard text exceeds 4 MiB"
);
let mut incomplete = vec![b'x'; MAX_CLIPBOARD_BYTES - 1];
incomplete.push(0xc3);
fs::write(fixture.0.join("state"), incomplete).unwrap();
assert_eq!(
clipboard.read().unwrap_err(),
"System clipboard is not valid UTF-8"
);
}
#[test]
fn command_failures_and_stderr_cannot_leak_clipboard_text() {
let fixture = Fixture::new();
let failed = fixture.command(
"failed",
"printf 'private clipboard payload' >&2\nprintf 'private clipboard payload'\nexit 23",
);
let mut clipboard = SystemClipboard::with_commands(failed.clone(), failed);
let error = clipboard.read().unwrap_err();
assert!(error.contains("failed"));
assert!(!error.contains("private clipboard payload"));
let error = clipboard.write("private clipboard payload").unwrap_err();
assert!(!error.contains("private clipboard payload"));
let missing = fixture.0.join("not-an-executable");
let mut clipboard = SystemClipboard::with_commands(missing.clone(), missing);
assert!(clipboard.read().unwrap_err().contains("Cannot start"));
assert!(
clipboard
.write("secret")
.unwrap_err()
.contains("Cannot start")
);
}
#[test]
fn ignored_stderr_does_not_block_large_writes() {
let fixture = Fixture::new();
let mut clipboard = fixture.clipboard(b"");
let state = fixture.0.join("state");
let state = format!("'{}'", state.to_str().unwrap().replace('\'', "'\\''"));
clipboard
.commands
.as_mut()
.unwrap()
.as_mut()
.unwrap()
.writer
.as_mut()
.unwrap()
.program = fixture.command(
"noisy",
&format!("/bin/dd if=/dev/zero bs=65536 count=8 >&2\nexec /bin/cat > {state}"),
);
let text = "λ\n".repeat(300_000);
clipboard.write(&text).unwrap();
assert_eq!(clipboard.read().unwrap(), text);
}
#[test]
fn failed_process_cleanup_kills_and_reaps_a_running_child() {
use std::io::Read;
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
unsafe extern "C" {
fn waitpid(pid: i32, status: *mut i32, options: i32) -> i32;
}
let fixture = Fixture::new();
let hung = fixture.command("running", "printf R\nwhile :; do :; done");
let mut command = Command::new(hung);
command
.process_group(0)
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = command.spawn().unwrap();
let pid = child.id() as i32;
let mut ready = [0];
child
.stdout
.as_mut()
.unwrap()
.read_exact(&mut ready)
.unwrap();
assert_eq!(ready, [b'R']);
drop(ClipboardProcess(Some(child)));
let mut status = 0;
// SAFETY: waitpid's output pointer is valid; WNOHANG cannot block this check.
assert_eq!(unsafe { waitpid(pid, &mut status, 1) }, -1);
assert_eq!(std::io::Error::last_os_error().raw_os_error(), Some(10));
}
#[test]
fn immediate_injected_timeout_terminates_nonterminating_commands() {
let fixture = Fixture::new();
let hung = fixture.command("hung", "while :; do :; done");
let mut clipboard = SystemClipboard::with_commands(hung.clone(), hung);
// No assertion depends on wall-clock speed or a deadline remaining in the future.
clipboard.timeout = Duration::ZERO;
assert_eq!(
clipboard.read().unwrap_err(),
"System clipboard command timed out"
);
assert_eq!(
clipboard.write("text").unwrap_err(),
"System clipboard command timed out"
);
}
+81
View File
@@ -0,0 +1,81 @@
use super::*;
unsafe extern "C" {
fn PasteboardSynchronize(pasteboard: Object) -> u32;
fn PasteboardGetItemIdentifier(pasteboard: Object, index: isize, item: *mut usize) -> i32;
fn PasteboardCopyItemFlavorData(
pasteboard: Object,
item: usize,
flavor: Object,
data: *mut Object,
) -> i32;
fn CFDataGetBytePtr(data: Object) -> *const u8;
fn CFDataGetLength(data: Object) -> isize;
}
#[test]
fn native_publication_preserves_plain_utf8_bytes_on_an_isolated_pasteboard() {
// A private pasteboard leaves the user's clipboard untouched.
let _pool = unsafe { AutoreleasePool(objc_autoreleasePoolPush()) };
let name = unsafe {
Owned::new(CFStringCreateWithCString(
ptr::null(),
c"vamp-native-clipboard-regression".as_ptr(),
0x0800_0100,
))
.unwrap()
};
let board = unsafe {
let mut board = ptr::null();
check(PasteboardCreate(name.0, &mut board)).unwrap();
Owned::new(board).unwrap()
};
let reader = unsafe {
let send: unsafe extern "C" fn(Object, Object, Object) -> Object =
std::mem::transmute(objc_msgSend as unsafe extern "C" fn());
send(
objc_getClass(c"NSPasteboard".as_ptr()),
sel_registerName(c"pasteboardWithName:".as_ptr()),
name.0,
)
};
for text in [
"",
"é e\u{301} 👨‍💻\n\0tail",
"{\\rtf1 plain text}",
"%!PS-Adobe-3.0\nplain",
] {
write_to(name.0, text).unwrap();
assert_eq!(read_from(reader).unwrap(), text);
unsafe {
PasteboardSynchronize(board.0);
let mut item = 0;
check(PasteboardGetItemIdentifier(board.0, 1, &mut item)).unwrap();
let flavor = Owned::new(CFStringCreateWithCString(
ptr::null(),
c"public.utf8-plain-text".as_ptr(),
0x0800_0100,
))
.unwrap();
let mut data = ptr::null();
check(PasteboardCopyItemFlavorData(
board.0, item, flavor.0, &mut data,
))
.unwrap();
let data = Owned::new(data).unwrap();
let length = CFDataGetLength(data.0) as usize;
let bytes = if length == 0 {
&[]
} else {
std::slice::from_raw_parts(CFDataGetBytePtr(data.0), length)
};
assert_eq!(bytes, text.as_bytes());
}
}
let maximum = "x".repeat(super::super::MAX_CLIPBOARD_BYTES);
write_to(name.0, &maximum).unwrap();
assert_eq!(read_from(reader).unwrap(), maximum);
write_to(name.0, &(maximum + "x")).unwrap();
assert_eq!(
read_from(reader).unwrap_err(),
"System clipboard text exceeds 4 MiB"
);
}
+3656
View File
File diff suppressed because it is too large Load Diff
+16087
View File
File diff suppressed because it is too large Load Diff
+982
View File
@@ -0,0 +1,982 @@
use super::*;
#[test]
fn virtual_catalog_compacts_and_retains_file_directory_collisions() {
let mut picker = FilePicker::from_paths(
PathBuf::new(),
["x", "x/y", "src/deep/deleted.rs", "src/deep/deleted.rs"]
.map(PathBuf::from)
.into_iter()
.enumerate(),
);
assert_eq!(
picker
.entries()
.map(|row| row.label.to_owned())
.collect::<Vec<_>>(),
["src/deep", "x", "x"]
);
picker.reveal_path(Path::new("x"));
assert!(!picker.entries().nth(picker.selected).unwrap().is_dir);
assert_eq!(picker.accept(), Some(PathBuf::from("x")));
picker.reveal_path(Path::new("x/y"));
assert_eq!(picker.accept(), Some(PathBuf::from("x/y")));
picker.reveal_path(Path::new("src/deep/deleted.rs"));
assert_eq!(picker.accept(), Some(PathBuf::from("src/deep/deleted.rs")));
assert_eq!(picker.entries().filter(|row| !row.is_dir).count(), 3);
let expected_index = |row: &FileRow<'_>| {
if row.is_dir {
None
} else {
Some(match row.path.to_str().unwrap() {
"x" => 0,
"x/y" => 1,
"src/deep/deleted.rs" => 2,
path => panic!("unexpected path {path}"),
})
}
};
for view in [FileView::List, FileView::Tree] {
picker.set_view(view);
for query in ["", "x", "deleted"] {
picker.query = query.into();
picker.refilter();
let rows: Vec<_> = picker.entries().collect();
assert!(!rows.is_empty());
for row in rows {
assert_eq!(row.catalog_index, expected_index(&row));
assert!(row.status.is_none() && row.old_path.is_none());
}
}
}
}
use std::sync::atomic::{AtomicU64, Ordering};
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
struct Directory(PathBuf);
impl Directory {
fn new() -> Self {
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("vamp-files-test-{}-{id}", std::process::id()));
fs::create_dir(&path).unwrap();
Self(path)
}
fn put(&self, name: &str, text: &str) {
let path = self.0.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
fn scan(&self) -> FilePicker {
FilePicker::scan(self.0.clone(), FilePresentation::Floating, FileView::List).unwrap()
}
}
impl Drop for Directory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn paths(picker: &FilePicker) -> Vec<PathBuf> {
picker
.entries()
.map(|entry| entry.path.to_owned())
.collect()
}
#[test]
fn fuzzy_path_tokens_preserve_tree_context_selection_and_open_paths() {
let directory = Directory::new();
let names = [
"src/fuzzy_parser.rs",
"src/über_parser.rs",
"src/other.rs",
"tests/fuzzy_parser.rs",
];
for name in names {
directory.put(name, "");
}
for mut picker in [
directory.scan(),
FilePicker::from_paths(
directory.0.clone(),
names.map(PathBuf::from).into_iter().enumerate(),
),
] {
for view in [FileView::Tree, FileView::List] {
picker.set_view(view);
for (query, expected) in [
("sr/fp RS", "src/fuzzy_parser.rs"),
("ÜPR SRC", "src/über_parser.rs"),
] {
picker.query = query.into();
picker.refilter();
assert_eq!(
picker
.entries()
.filter(|row| !row.is_dir)
.map(|row| row.path)
.collect::<Vec<_>>(),
[Path::new(expected)]
);
assert_eq!(selected_path(&picker), Path::new(expected));
assert_eq!(picker.accept(), Some(directory.0.join(expected)));
if view == FileView::Tree {
assert_eq!(paths(&picker), [Path::new("src"), Path::new(expected)]);
}
}
picker.query = "rs/ü".into();
picker.refilter();
assert!(picker.entries().next().is_none());
picker.clear_filter();
assert_eq!(selected_path(&picker), Path::new("src/über_parser.rs"));
}
}
}
#[test]
fn scanned_files_and_directories_have_no_catalog_index() {
let directory = Directory::new();
directory.put("nested/file.txt", "");
let mut picker = directory.scan();
for view in [FileView::List, FileView::Tree] {
picker.set_view(view);
picker.reveal_path(Path::new("nested/file.txt"));
assert!(picker.entries().all(|row| row.catalog_index.is_none()));
assert!(
picker
.entries()
.all(|row| row.status.is_none() && row.old_path.is_none())
);
}
}
fn repository_change(path: &str, status: FileStatus, old_path: Option<&str>) -> WorkingFileChange {
WorkingFileChange {
path: path.into(),
status,
old_path: old_path.map(PathBuf::from),
}
}
#[test]
fn repository_metadata_keeps_clean_files_selection_and_filtered_views() {
let directory = Directory::new();
for name in ["src/clean.rs", "src/modified.rs", "added.rs", "renamed.rs"] {
directory.put(name, "");
}
let mut picker = directory.scan();
picker.presentation = FilePresentation::Docked;
picker.set_view(FileView::Tree);
picker.reveal_path(Path::new("src/clean.rs"));
picker.query = "src".into();
picker.refilter();
picker.focused = false;
let changes = vec![
repository_change("src/modified.rs", FileStatus::Modified, None),
repository_change("added.rs", FileStatus::Added, None),
repository_change("renamed.rs", FileStatus::Renamed, Some("old.rs")),
repository_change("vanished/deep/deleted.rs", FileStatus::Removed, None),
];
assert!(picker.set_repository_changes(changes.clone()));
assert!(!picker.set_repository_changes(changes));
assert_eq!(selected_path(&picker), Path::new("src/clean.rs"));
assert_eq!(picker.query, "src");
assert!(!picker.focused);
assert_eq!(picker.presentation, FilePresentation::Docked);
for view in [FileView::List, FileView::Tree] {
picker.set_view(view);
for query in ["", "src", "modified.rs", "deleted.rs"] {
picker.query = query.into();
picker.refilter();
let rows: Vec<_> = picker.entries().collect();
assert!(!rows.is_empty());
for row in rows {
assert!(row.catalog_index.is_none());
if row.is_dir || row.path == Path::new("src/clean.rs") {
assert!(row.status.is_none() && row.old_path.is_none());
} else {
let status = match row.path.to_str().unwrap() {
"src/modified.rs" => FileStatus::Modified,
"added.rs" => FileStatus::Added,
"renamed.rs" => FileStatus::Renamed,
"vanished/deep/deleted.rs" => FileStatus::Removed,
other => panic!("unexpected {other}"),
};
assert_eq!(row.status, Some(status));
assert_eq!(
row.old_path,
(status == FileStatus::Renamed).then(|| Path::new("old.rs"))
);
}
}
if query.is_empty() || query == "src" {
assert!(
picker
.entries()
.any(|row| row.path == Path::new("src/clean.rs"))
);
}
}
}
picker.set_view(FileView::List);
picker.clear_filter();
picker.reveal_path(Path::new("src/clean.rs"));
assert!(picker.set_repository_changes(Vec::new()));
assert_eq!(selected_path(&picker), Path::new("src/clean.rs"));
assert_eq!(picker.entries().len(), 4);
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
assert!(
picker
.entries()
.all(|row| row.status.is_none() && row.old_path.is_none())
);
assert!(!picker.set_repository_changes(Vec::new()));
}
#[test]
fn deleted_catalog_leaves_survive_refresh_and_restored_files_survive_clear() {
let directory = Directory::new();
directory.put("keep.txt", "");
let mut picker = directory.scan();
let deleted = Path::new("lost/deep/deleted.txt");
picker.set_repository_changes(vec![repository_change(
"lost/deep/deleted.txt",
FileStatus::Removed,
None,
)]);
picker.set_view(FileView::Tree);
assert_eq!(
picker.entries().find(|row| row.is_dir).unwrap().label,
"lost/deep"
);
picker.reveal_path(deleted);
picker.query = "deleted".into();
picker.refilter();
picker.start_search();
picker.focused = false;
picker.refresh();
assert_eq!(selected_path(&picker), deleted);
assert!(picker.searching && !picker.focused);
assert_eq!(picker.query, "deleted");
assert_eq!(picker.accept(), Some(directory.0.join(deleted)));
assert!(!directory.0.join(deleted).exists());
assert_eq!(
picker.entries().find(|row| !row.is_dir).unwrap().status,
Some(FileStatus::Removed)
);
directory.put("lost/deep/deleted.txt", "restored");
assert!(picker.set_repository_changes(vec![repository_change(
"lost/deep/deleted.txt",
FileStatus::Removed,
None,
)]));
picker.refresh();
assert_eq!(selected_path(&picker), deleted);
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
assert!(picker.set_repository_changes(Vec::new()));
assert_eq!(selected_path(&picker), deleted);
assert_eq!(picker.accept(), Some(directory.0.join(deleted)));
assert_eq!(
fs::read_to_string(directory.0.join(deleted)).unwrap(),
"restored"
);
assert!(picker.entries().all(|row| row.status.is_none()));
fs::remove_dir_all(directory.0.join("lost")).unwrap();
picker.refresh();
picker.set_repository_changes(vec![repository_change(
"lost/deep/deleted.txt",
FileStatus::Removed,
None,
)]);
picker.reveal_path(deleted);
picker.set_repository_changes(Vec::new());
assert!(picker.entries().len() == 0);
picker.clear_filter();
assert_eq!(paths(&picker), [PathBuf::from("keep.txt")]);
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
}
#[test]
fn repository_metadata_cannot_introduce_unsafe_or_existing_excluded_paths() {
let directory = Directory::new();
directory.put(".gitignore", "ignored/\n");
directory.put("ignored/hidden.txt", "");
directory.put("directory/keep.txt", "");
let mut picker = directory.scan();
let mut changes: Vec<_> = [
"",
".",
"../outside.txt",
"directory/../../outside.txt",
".git/secret",
"nested/.jj/secret",
"/outside.txt",
"directory",
"ignored/hidden.txt",
]
.into_iter()
.map(|path| repository_change(path, FileStatus::Removed, None))
.collect();
#[cfg(unix)]
{
std::os::unix::fs::symlink(&directory.0, directory.0.join("link")).unwrap();
changes.push(repository_change(
"link/missing.txt",
FileStatus::Removed,
None,
));
}
changes.push(repository_change("missing.txt", FileStatus::Removed, None));
picker.set_repository_changes(changes);
assert_eq!(
paths(&picker),
[".gitignore", "directory/keep.txt", "missing.txt"].map(PathBuf::from)
);
assert_eq!(
picker.entries().filter(|row| row.status.is_some()).count(),
1
);
assert_eq!(
picker.entries().last().unwrap().status,
Some(FileStatus::Removed)
);
assert!(!directory.0.join("missing.txt").exists());
}
#[test]
fn nested_ignore_negation_hidden_files_and_vcs_metadata() {
let directory = Directory::new();
directory.put(".gitignore", "*.log\nignored/\n");
directory.put("nested/.gitignore", "!keep.log\nlocal.txt\n");
for name in [
"gone.log",
"nested/gone.log",
"nested/keep.log",
"nested/local.txt",
"nested/ok.txt",
"ignored/hidden.txt",
".hidden",
".git/objects/secret",
"nested/.git/secret",
".jj/secret",
"nested/.jj/secret",
] {
directory.put(name, "text");
}
let picker = directory.scan();
assert_eq!(
paths(&picker),
[
".gitignore",
".hidden",
"nested/.gitignore",
"nested/keep.log",
"nested/ok.txt"
]
.map(PathBuf::from)
);
assert!(picker.error.is_none());
}
#[test]
fn literal_unicode_tokens_grapheme_deletion_and_navigation() {
let directory = Directory::new();
for name in ["a space 界[e\u{301}].txt", "b.txt", "c.txt"] {
directory.put(name, "text");
}
let mut picker = directory.scan();
picker.navigate(false);
assert_eq!(picker.selected, 2);
picker.navigate(true);
assert_eq!(picker.selected, 0);
picker.query = "界 SPACE [E\u{301}]".into();
picker.refilter();
assert_eq!(paths(&picker), [PathBuf::from("a space 界[e\u{301}].txt")]);
picker.query = "e\u{301}👩‍💻".into();
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
assert_eq!(picker.query, "e\u{301}");
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
assert_eq!(picker.query, "");
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
assert_eq!(picker.entries().len(), 3);
picker.query = "no match".into();
picker.refilter();
for forward in [true, false] {
picker.navigate(forward);
assert_eq!(picker.selected, 0);
assert_eq!(picker.entries().len(), 0);
}
}
#[test]
fn word_and_line_deletion_refilter_only_editable_queries() {
use crate::editor::Input;
for view in [FileView::List, FileView::Tree] {
let mut picker = FilePicker::from_paths(
PathBuf::new(),
["alpha one.txt", "alpha two.txt", "beta.txt"]
.map(PathBuf::from)
.into_iter()
.enumerate(),
);
picker.set_view(view);
picker.query = "alpha missing".into();
picker.refilter();
assert_eq!(picker.entries().len(), 0);
if view == FileView::Tree {
for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] {
assert!(!picker.handle_filter_input(&input));
assert_eq!(picker.query, "alpha missing");
}
picker.start_search();
}
assert!(picker.handle_filter_input(&Input::AltBackspace));
assert_eq!(picker.query, "alpha ");
assert_eq!(picker.entries().len(), 2);
assert!(picker.handle_filter_input(&Input::SuperBackspace));
assert_eq!(picker.query, "");
assert_eq!(picker.entries().len(), 3);
for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] {
assert!(picker.handle_filter_input(&input));
assert_eq!(picker.entries().len(), 3);
}
}
}
#[test]
fn empty_scan_refresh_and_failure_preserve_reachable_state() {
let directory = Directory::new();
let mut picker = directory.scan();
picker.navigate(false);
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
assert_eq!(picker.entries().len(), 0);
directory.put("new.txt", "text");
picker.query = "new".into();
picker.refresh();
assert_eq!(picker.query, "new");
assert_eq!(paths(&picker), [PathBuf::from("new.txt")]);
fs::remove_dir_all(&directory.0).unwrap();
picker.refresh();
assert_eq!(paths(&picker), [PathBuf::from("new.txt")]);
assert!(picker.error.as_ref().unwrap().contains("Refresh failed"));
assert!(
FilePicker::scan(
directory.0.clone(),
FilePresentation::Docked,
FileView::Tree
)
.is_err()
);
}
#[test]
fn malformed_ignore_warning_survives_query_and_navigation() {
let directory = Directory::new();
directory.put(".gitignore", "[z-a]\n");
directory.put("ok.txt", "text");
let mut picker = directory.scan();
assert!(picker.error.as_ref().unwrap().contains("Incomplete scan"));
picker.query = "ok".into();
picker.refilter();
assert_eq!(paths(&picker), [PathBuf::from("ok.txt")]);
picker.navigate(true);
assert!(picker.error.as_ref().unwrap().contains("Incomplete scan"));
directory.put(".gitignore", "");
picker.refresh();
assert!(picker.error.is_none());
}
#[test]
fn only_project_ignore_rules_apply_and_non_directory_roots_fail() {
let directory = Directory::new();
directory.put(".gitignore", "*.txt\n");
directory.put("project/visible.txt", "text");
let root = directory.0.join("project");
let picker = FilePicker::scan(root, FilePresentation::Floating, FileView::List).unwrap();
assert_eq!(paths(&picker), [PathBuf::from("visible.txt")]);
assert!(
FilePicker::scan(
directory.0.join("project/visible.txt"),
FilePresentation::Docked,
FileView::Tree,
)
.is_err()
);
}
#[cfg(unix)]
#[test]
fn symlink_loops_and_file_links_are_not_followed_and_raw_paths_survive() {
use std::os::unix::fs::symlink;
let directory = Directory::new();
directory.put("real.txt", "text");
symlink(&directory.0, directory.0.join("loop")).unwrap();
symlink(directory.0.join("real.txt"), directory.0.join("alias.txt")).unwrap();
let raw = PathBuf::from("raw界\n.txt");
fs::write(directory.0.join(&raw), "text").unwrap();
let picker = directory.scan();
assert_eq!(picker.entries().len(), 2);
let entry = picker
.entries()
.find(|entry| entry.path == raw.as_path())
.unwrap();
assert!(!entry.label.chars().any(char::is_control));
assert_eq!(
fs::read_to_string(directory.0.join(entry.path)).unwrap(),
"text"
);
}
fn tree(directory: &Directory) -> FilePicker {
FilePicker::scan(
directory.0.clone(),
FilePresentation::Docked,
FileView::Tree,
)
.unwrap()
}
fn select(picker: &mut FilePicker, path: &str) {
let selected = picker
.entries()
.position(|row| row.path == Path::new(path))
.unwrap();
picker.selected = selected;
}
fn selected_path(picker: &FilePicker) -> &Path {
picker.entries().nth(picker.selected).unwrap().path
}
#[test]
fn tree_orders_folders_first_keeps_empty_dirs_and_compacts_only_directory_chains() {
let directory = Directory::new();
directory.put("a.txt", "");
directory.put("folder/only.txt", "");
directory.put("chain/one/two/a.txt", "");
directory.put("chain/one/two/z.txt", "");
fs::create_dir(directory.0.join("empty")).unwrap();
let mut picker = tree(&directory);
assert_eq!(picker.view, FileView::Tree);
assert!(!picker.searching);
assert_eq!(
picker
.entries()
.map(|row| (row.label, row.is_dir, row.depth))
.collect::<Vec<_>>(),
[
("chain/one/two", true, 0),
("empty", true, 0),
("folder", true, 0),
("a.txt", false, 0)
]
);
assert_eq!(selected_path(&picker), Path::new("chain/one/two"));
picker.right();
assert_eq!(
picker
.entries()
.map(|row| (row.label, row.depth))
.collect::<Vec<_>>(),
[
("chain/one/two", 0),
("a.txt", 1),
("z.txt", 1),
("empty", 0),
("folder", 0),
("a.txt", 0)
]
);
picker.right();
assert_eq!(selected_path(&picker), Path::new("chain/one/two/a.txt"));
let rows = paths(&picker);
picker.right();
assert_eq!(paths(&picker), rows);
picker.left();
assert_eq!(selected_path(&picker), Path::new("chain/one/two"));
picker.left();
assert!(!picker.entries().next().unwrap().expanded);
picker.left();
assert_eq!(picker.selected, 0);
select(&mut picker, "folder");
assert!(picker.accept().is_none());
assert_eq!(
picker.entries().nth(picker.selected + 1).unwrap().label,
"only.txt"
);
picker.right();
assert_eq!(picker.accept(), Some(directory.0.join("folder/only.txt")));
select(&mut picker, "empty");
picker.right();
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
picker.right();
assert_eq!(selected_path(&picker), Path::new("empty"));
}
#[test]
fn tree_search_reveals_ancestors_without_mutating_saved_expansion_or_compaction() {
let directory = Directory::new();
directory.put("src/module/deep/needle.txt", "");
directory.put("src/module/sibling.txt", "");
directory.put("src/root.txt", "");
directory.put("other/keep.txt", "");
let mut picker = tree(&directory);
select(&mut picker, "other");
picker.right();
select(&mut picker, "src");
let expanded = picker.expanded.clone();
picker.start_search();
picker.query = "NEEDLE".into();
picker.refilter();
assert_eq!(
picker
.entries()
.map(|row| (row.label, row.depth))
.collect::<Vec<_>>(),
[("src", 0), ("module", 1), ("deep", 2), ("needle.txt", 3)]
);
assert_eq!(
selected_path(&picker),
Path::new("src/module/deep/needle.txt")
);
assert_eq!(picker.expanded, expanded);
picker.left();
let filtered = paths(&picker);
picker.finish_search();
assert_eq!(picker.query, "NEEDLE");
assert_eq!(paths(&picker), filtered);
assert_eq!(selected_path(&picker), Path::new("src/module/deep"));
assert_eq!(picker.expanded, expanded);
picker.start_search();
picker.query = "deep".into();
picker.refilter();
select(&mut picker, "src/module/deep");
assert!(picker.accept().is_none());
assert!(!picker.searching);
assert_eq!(picker.query, "deep");
assert_eq!(selected_path(&picker), Path::new("src/module/deep"));
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
assert_eq!(picker.expanded, expanded);
picker.right();
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
assert_eq!(picker.query, "deep");
let chain = Directory::new();
chain.put("one/two/three/a.txt", "");
chain.put("one/two/three/b.txt", "");
let mut picker = tree(&chain);
picker.start_search();
picker.query = "a.txt".into();
picker.refilter();
assert_eq!(
picker.entries().map(|row| row.label).collect::<Vec<_>>(),
["one/two/three", "a.txt"]
);
}
#[test]
fn view_switches_restore_directory_choices_and_reveal_explicit_file_choices() {
let directory = Directory::new();
directory.put("src/a.txt", "");
directory.put("src/b.txt", "");
directory.put("z.txt", "");
let mut picker = tree(&directory);
picker.right();
let open = paths(&picker);
picker.set_view(FileView::List);
assert_eq!(
paths(&picker),
["src/a.txt", "src/b.txt", "z.txt"].map(PathBuf::from)
);
picker.set_view(FileView::Tree);
assert_eq!(selected_path(&picker), Path::new("src"));
assert_eq!(paths(&picker), open);
picker.left();
picker.toggle_view();
picker.navigate(true);
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
picker.toggle_view();
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
assert!(picker.entries().next().unwrap().expanded);
picker.left();
picker.toggle_view();
picker.toggle_view();
assert_eq!(selected_path(&picker), Path::new("src"));
}
#[test]
fn filtering_and_refresh_preserve_raw_selection_and_surviving_expansion() {
let directory = Directory::new();
directory.put("src/a.txt", "");
directory.put("src/b.txt", "");
directory.put("z.txt", "");
let mut picker = directory.scan();
select(&mut picker, "src/b.txt");
picker.query = "absent".into();
picker.refilter();
assert_eq!(picker.entries().len(), 0);
picker.query.clear();
picker.refilter();
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
picker.set_view(FileView::Tree);
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
fs::remove_file(directory.0.join("src/b.txt")).unwrap();
directory.put("src/c.txt", "");
picker.refresh();
assert_eq!(selected_path(&picker), Path::new("src"));
assert!(picker.entries().next().unwrap().expanded);
assert!(paths(&picker).contains(&PathBuf::from("src/c.txt")));
fs::remove_dir_all(directory.0.join("src")).unwrap();
picker.refresh();
assert_eq!(selected_path(&picker), Path::new("z.txt"));
assert!(picker.expanded.is_empty());
picker.toggle_view();
picker.toggle_view();
assert_eq!(selected_path(&picker), Path::new("z.txt"));
}
#[test]
fn tree_search_subfocus_and_expansion_survive_view_toggles_and_refresh() {
let directory = Directory::new();
directory.put("src/deep/a.txt", "");
directory.put("src/other.txt", "");
let mut picker = tree(&directory);
picker.start_search();
assert!(picker.searching);
assert_eq!(picker.entries().len(), 4);
assert!(picker.expanded.is_empty());
picker.toggle_view();
assert_eq!(picker.view, FileView::List);
picker.toggle_view();
assert!(picker.searching);
assert!(picker.expanded.is_empty());
picker.query = "a.txt".into();
picker.refilter();
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
picker.refresh();
assert!(picker.searching);
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
let filtered = paths(&picker);
picker.finish_search();
assert!(!picker.searching);
assert_eq!(picker.query, "a.txt");
assert_eq!(paths(&picker), filtered);
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
assert!(picker.expanded.is_empty());
picker.toggle_view();
picker.refresh();
picker.toggle_view();
assert!(!picker.searching);
assert_eq!(picker.query, "a.txt");
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
picker.start_search();
assert!(picker.searching);
assert_eq!(picker.query, "a.txt");
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
}
#[test]
fn clearing_filter_preserves_mode_focus_current_path_and_empty_result_fallback() {
let directory = Directory::new();
directory.put("src/deep/needle.txt", "");
directory.put("src/other.txt", "");
directory.put("other/keep.txt", "");
for (view, searching) in [
(FileView::Tree, false),
(FileView::Tree, true),
(FileView::List, false),
] {
let mut picker = tree(&directory);
select(&mut picker, "other");
picker.right();
let expanded = picker.expanded.clone();
picker.set_view(view);
if view == FileView::Tree {
picker.start_search();
}
picker.query = "needle".into();
picker.refilter();
if !searching && view == FileView::Tree {
picker.finish_search();
}
let expected = Path::new("src/deep/needle.txt");
assert_eq!(selected_path(&picker), expected);
picker.clear_filter();
assert!(picker.query.is_empty());
assert_eq!(picker.searching, searching);
assert!(picker.focused);
assert_eq!(selected_path(&picker), expected);
assert!(picker.expanded.is_superset(&expanded));
assert!(paths(&picker).contains(&PathBuf::from("other/keep.txt")));
let rows = paths(&picker);
let expanded = picker.expanded.clone();
picker.clear_filter();
assert_eq!(paths(&picker), rows);
assert_eq!(picker.expanded, expanded);
assert_eq!(selected_path(&picker), expected);
assert_eq!(picker.searching, searching);
picker.query = "absent".into();
picker.refilter();
picker.navigate(true);
picker.navigate(false);
assert!(picker.rows.is_empty());
picker.refresh();
assert!(picker.rows.is_empty());
picker.clear_filter();
assert_eq!(selected_path(&picker), expected);
assert_eq!(picker.searching, searching);
assert!(picker.focused);
}
let empty = Directory::new();
let mut picker = tree(&empty);
picker.start_search();
picker.query = "absent".into();
picker.refilter();
picker.finish_search();
picker.clear_filter();
assert!(picker.rows.is_empty());
assert_eq!(picker.selected, 0);
assert!(!picker.searching);
assert!(picker.focused);
}
#[test]
fn filtered_navigation_toggles_context_folders_and_query_edits_reveal_matches() {
let directory = Directory::new();
directory.put("src/deep/needle.txt", "");
directory.put("src/other.txt", "");
let mut picker = tree(&directory);
picker.start_search();
picker.query = "needle".into();
picker.refilter();
let expanded = picker.expanded.clone();
picker.left();
assert_eq!(selected_path(&picker), Path::new("src/deep"));
picker.right();
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
assert_eq!(picker.expanded, expanded);
picker.finish_search();
picker.left();
picker.left();
assert_eq!(selected_path(&picker), Path::new("src/deep"));
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
assert_eq!(picker.query, "needle");
assert!(!paths(&picker).contains(&PathBuf::from("src/deep/needle.txt")));
picker.start_search();
picker.finish_search();
assert_eq!(selected_path(&picker), Path::new("src/deep"));
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
picker.right();
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
picker.right();
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
picker.left();
assert!(picker.accept().is_none());
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
assert!(picker.accept().is_none());
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
picker.start_search();
assert!(picker.accept().is_none());
assert!(!picker.searching);
assert_eq!(picker.query, "needle");
assert_eq!(selected_path(&picker), Path::new("src/deep"));
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
picker.start_search();
picker.query.push_str(".txt");
picker.refilter();
assert!(picker.filter_collapsed.is_empty());
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
picker.query.clear();
picker.refilter();
picker.finish_search();
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
}
#[test]
fn filtered_collapse_survives_refresh_compaction_and_view_switching_by_raw_path() {
let directory = Directory::new();
directory.put("one/a.txt", "");
directory.put("one/two/needle.txt", "");
let mut picker = tree(&directory);
picker.start_search();
picker.query = "needle".into();
picker.refilter();
picker.finish_search();
picker.left();
picker.left();
picker.left();
picker.left();
assert_eq!(selected_path(&picker), Path::new("one"));
assert_eq!(paths(&picker), [PathBuf::from("one")]);
picker.toggle_view();
assert_eq!(selected_path(&picker), Path::new("one/two/needle.txt"));
picker.toggle_view();
assert_eq!(paths(&picker), [PathBuf::from("one")]);
assert!(!picker.searching);
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
picker.focused = false;
picker.refresh();
assert_eq!(selected_path(&picker), Path::new("one/two"));
assert_eq!(paths(&picker), [PathBuf::from("one/two")]);
assert!(!picker.searching && !picker.focused);
assert_eq!(picker.query, "needle");
directory.put("one/a.txt", "");
picker.refresh();
assert_eq!(selected_path(&picker), Path::new("one"));
assert_eq!(paths(&picker), [PathBuf::from("one")]);
picker.right();
picker.right();
assert_eq!(selected_path(&picker), Path::new("one/two"));
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
picker.right();
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
picker.right();
assert_eq!(selected_path(&picker), Path::new("one/two/needle.txt"));
fs::remove_file(directory.0.join("one/two/needle.txt")).unwrap();
picker.refresh();
assert!(picker.entries().len() == 0);
picker.clear_filter();
assert_eq!(selected_path(&picker), Path::new("one/two"));
assert!(!picker.searching && !picker.focused);
}
#[test]
fn refresh_preserves_expansion_when_directory_chains_merge_and_split() {
let directory = Directory::new();
directory.put("one/a.txt", "");
directory.put("one/two/b.txt", "");
let mut picker = tree(&directory);
picker.right();
assert_eq!(
paths(&picker),
["one", "one/two", "one/a.txt"].map(PathBuf::from)
);
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
picker.refresh();
assert_eq!(selected_path(&picker), Path::new("one/two"));
assert_eq!(
paths(&picker),
["one/two", "one/two/b.txt"].map(PathBuf::from)
);
assert!(picker.entries().next().unwrap().expanded);
directory.put("one/a.txt", "");
picker.refresh();
assert_eq!(selected_path(&picker), Path::new("one/two"));
assert_eq!(
paths(&picker),
["one", "one/two", "one/a.txt"].map(PathBuf::from)
);
assert!(picker.entries().next().unwrap().expanded);
assert!(!picker.entries().nth(1).unwrap().expanded);
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
picker.refresh();
picker.left();
assert_eq!(paths(&picker), [PathBuf::from("one/two")]);
directory.put("one/a.txt", "");
picker.refresh();
assert_eq!(paths(&picker), [PathBuf::from("one")]);
assert!(!picker.entries().next().unwrap().expanded);
}
+1066
View File
File diff suppressed because it is too large Load Diff
+425
View File
@@ -0,0 +1,425 @@
use super::*;
use std::ffi::OsString;
static NEXT_TEST: AtomicU64 = AtomicU64::new(0);
struct Temporary(PathBuf);
impl Temporary {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"vamp-gutter-test-{}-{}",
std::process::id(),
NEXT_TEST.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for Temporary {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn command(root: &Path, program: &str, args: &[&str]) -> String {
let mut command = Command::new(program);
if program == "jj" {
command.args([
"--config=user.name=\"Gutter Author\"",
"--config=user.email=\"gutter@example.test\"",
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
]);
}
let output = command
.args(args)
.current_dir(root)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
.env("JJ_CONFIG", "/dev/null")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().into()
}
fn git_fixture() -> (Temporary, PathBuf) {
let temp = Temporary::new();
command(&temp.0, "git", &["init", "--quiet", "--template="]);
command(&temp.0, "git", &["config", "user.name", "Gutter Author"]);
command(
&temp.0,
"git",
&["config", "user.email", "gutter@example.test"],
);
let path = temp.0.join("-literal [file]\n\t\".txt");
fs::write(&path, "first\nsecond\nthird\n").unwrap();
command(
&temp.0,
"git",
&["add", "--", path.file_name().unwrap().to_str().unwrap()],
);
command(&temp.0, "git", &["commit", "--quiet", "-m", "Baseline"]);
(temp, path)
}
#[test]
fn maps_replacements_surplus_deletions_empty_and_eof_rows() {
use Change::*;
for (before, after, expected) in [
("first\nsecond\n", "first\nsecond\n", vec![None, None, None]),
(
"first\r\nsecond\r\n",
"first\nsecond\n",
vec![None, None, None],
),
("first\rsecond\r", "first\nsecond\n", vec![None, None, None]),
(
"first\r\nsecond\r\nthird\r\n",
"first\nchanged\nthird\n",
vec![None, Some(Modified), None, None],
),
(
"first\nsecond\nthird\n",
"first\rchanged\rthird\r",
vec![None, Some(Modified), None, None],
),
("first\n", "first\nnew\n", vec![None, Some(Added), None]),
(
"old\n",
"changed\nsurplus\n",
vec![Some(Modified), Some(Added), None],
),
(
"old\nextra\nlast\n",
"changed\nlast\n",
vec![Some(Modified), None, None],
),
(
"first\nremoved\nlast\n",
"first\nlast\n",
vec![None, Some(Removed), None],
),
("first\nremoved\n", "first\n", vec![None, Some(Removed)]),
("first\nremoved", "first", vec![Some(Modified)]),
("first\nremoved", "first\n", vec![None, Some(Removed)]),
("removed\n", "", vec![Some(Removed)]),
("", "new\n", vec![Some(Added), None]),
("", "", vec![None]),
("same", "same\n", vec![Some(Modified), None]),
] {
assert_eq!(changes(before, after), expected, "{before:?} -> {after:?}");
}
}
#[test]
fn git_crlf_baseline_matches_normalized_editor_rows() {
let (temp, _) = git_fixture();
let path = temp.0.join("crlf");
fs::write(&path, "first\r\nsecond\r\nthird\r\n").unwrap();
command(&temp.0, "git", &["add", "--", "crlf"]);
command(
&temp.0,
"git",
&["commit", "--quiet", "-m", "CRLF baseline"],
);
assert_eq!(
read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(),
vec![None, None, None, None]
);
assert_eq!(
read_file(&path, "first\nchanged\nthird\n", &|| false).unwrap(),
vec![None, Some(Change::Modified), None, None]
);
}
#[test]
fn git_head_includes_staged_disk_and_unsaved_edits_without_writes() {
let (temp, path) = git_fixture();
fs::write(&path, "staged\nsecond\nthird\n").unwrap();
command(
&temp.0,
"git",
&["add", "--", path.file_name().unwrap().to_str().unwrap()],
);
fs::write(&path, "staged\ndisk\nthird\n").unwrap();
let index = fs::read(temp.0.join(".git/index")).unwrap();
let head = command(&temp.0, "git", &["rev-parse", "HEAD"]);
assert_eq!(
read_file(&path, "staged\ndisk\nunsaved\n", &|| false).unwrap(),
vec![
Some(Change::Modified),
Some(Change::Modified),
Some(Change::Modified),
None
]
);
assert_eq!(fs::read(temp.0.join(".git/index")).unwrap(), index);
assert_eq!(command(&temp.0, "git", &["rev-parse", "HEAD"]), head);
assert_eq!(fs::read_to_string(&path).unwrap(), "staged\ndisk\nthird\n");
fs::remove_file(&path).unwrap();
assert_eq!(
read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(),
vec![None, None, None, None]
);
assert_eq!(
read_file(&temp.0.join("new/named"), "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
let untracked = temp.0.join("untracked");
fs::write(&untracked, "unsaved\n").unwrap();
assert_eq!(
read_file(&untracked, "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
}
#[test]
fn unborn_untracked_missing_ignored_metadata_binary_and_symlink_paths() {
let temp = Temporary::new();
let path = temp.0.join("new/named");
assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty());
command(&temp.0, "git", &["init", "--quiet", "--template="]);
assert_eq!(
read_file(&path, "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
let disk = temp.0.join("untracked");
fs::write(&disk, "disk\n").unwrap();
assert_eq!(
read_file(&disk, "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
fs::write(temp.0.join(".gitignore"), "new/\nuntracked\n").unwrap();
assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty());
assert!(read_file(&disk, "unsaved\n", &|| false).unwrap().is_empty());
assert!(
read_file(&temp.0.join(".git/config"), "text\n", &|| false)
.unwrap()
.is_empty()
);
assert!(
read_file(&disk, "binary\0text", &|| false)
.unwrap()
.is_empty()
);
std::os::unix::fs::symlink(&disk, temp.0.join("link")).unwrap();
assert!(
read_file(&temp.0.join("link"), "text\n", &|| false)
.unwrap()
.is_empty()
);
std::os::unix::fs::symlink(&temp.0, temp.0.join("linked")).unwrap();
assert!(
read_file(&temp.0.join("linked/untracked"), "text\n", &|| false)
.unwrap()
.is_empty()
);
}
#[test]
fn git_non_utf8_filename_is_literal() {
use std::os::unix::ffi::OsStringExt;
let (temp, _) = git_fixture();
let name = OsString::from_vec(b"non-utf8-\xff".to_vec());
let path = temp.0.join(&name);
match fs::write(&path, "old\n") {
Ok(()) => {}
// APFS rejects non-UTF-8 names before repository code can read them.
Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => return,
Err(error) => panic!("Cannot create non-UTF-8 fixture: {error}"),
}
let mut add = git(&temp.0);
add.args(["add", "--"]).arg(&name);
checked(add, &|| false).unwrap();
command(&temp.0, "git", &["commit", "--quiet", "-m", "Byte path"]);
assert_eq!(
read_file(&path, "changed\n", &|| false).unwrap(),
vec![Some(Change::Modified), None]
);
}
#[test]
fn jj_parent_root_colocation_ignore_and_merge_queries_are_read_only() {
for colocate in ["--colocate", "--no-colocate"] {
let temp = Temporary::new();
command(&temp.0, "jj", &["git", "init", colocate, "."]);
let name = "-literal [file]\n\t\".txt";
let path = temp.0.join(name);
fs::write(&path, "original\n").unwrap();
assert_eq!(
read_file(&path, "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
fs::write(temp.0.join(".gitignore"), "ignored\n").unwrap();
assert!(
read_file(&temp.0.join("ignored"), "text\n", &|| false)
.unwrap()
.is_empty()
);
command(&temp.0, "jj", &["describe", "-m", "Baseline"]);
command(&temp.0, "jj", &["new"]);
let operation = command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id",
],
);
let store = store_for_jj(&temp.0).unwrap();
let refs = fs::read(store.join("HEAD")).unwrap();
fs::write(&path, "disk edit\n").unwrap();
assert_eq!(
read_file(&path, "inserted\noriginal\n", &|| false).unwrap(),
vec![Some(Change::Added), None, None]
);
assert_eq!(fs::read(store.join("HEAD")).unwrap(), refs);
assert_eq!(
command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id",
]
),
operation
);
let parent = command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"log",
"--no-graph",
"-r",
"@-",
"-T",
"commit_id",
],
);
command(&temp.0, "jj", &["new", "root()"]);
fs::write(temp.0.join("sibling"), "sibling\n").unwrap();
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
command(&temp.0, "jj", &["new", &parent, "@"]);
assert!(
read_file(&path, "original\n", &|| false)
.unwrap()
.is_empty()
);
}
}
#[test]
fn worker_retains_only_latest_batch_and_file_errors_keep_good_siblings() {
let (_temp, path) = git_fixture();
let mut worker = Worker::start().unwrap();
let oversized: Arc<str> = "x".repeat(FILE_LIMIT + 1).into();
for id in 0..20 {
worker
.request(
id,
vec![
(5, path.clone(), "first\nchanged\nthird\n".into()),
(8, path.clone(), Arc::clone(&oversized)),
],
None,
)
.unwrap();
}
loop {
if let Some((id, result, picker)) = worker.poll() {
assert!(picker.is_none());
assert_eq!(id, 19);
assert_eq!(result[0].0, 5);
assert_eq!(
result[0].1.as_ref().unwrap(),
&vec![None, Some(Change::Modified), None, None]
);
assert_eq!(result[1].0, 8);
assert_eq!(
result[1].1.as_ref().unwrap_err(),
"Gutter file exceeds 16 MiB"
);
break;
}
thread::yield_now();
}
assert_eq!(
worker
.request(
20,
vec![(0, path, "x".repeat(BATCH_LIMIT + 1).into())],
None
)
.unwrap_err()
.kind(),
io::ErrorKind::InvalidInput
);
}
#[test]
fn picker_status_is_optional_and_errors_do_not_discard_line_results() {
let (temp, path) = git_fixture();
let mut worker = Worker::start().unwrap();
worker
.request(
1,
vec![],
Some((
temp.0.clone(),
vec![(path.clone(), "dirty buffer\n".into())],
)),
)
.unwrap();
loop {
if let Some((id, lines, picker)) = worker.poll() {
assert_eq!(id, 1);
assert!(lines.is_empty());
let picker = picker.unwrap();
assert_eq!(picker.root, temp.0);
let changes = picker.result.unwrap();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].path, path.strip_prefix(&temp.0).unwrap());
assert_eq!(changes[0].status, crate::diff::FileStatus::Modified);
break;
}
thread::yield_now();
}
worker
.request(
2,
vec![(4, path.clone(), "first\nchanged\nthird\n".into())],
Some((
temp.0.clone(),
vec![(path, "x".repeat(FILE_LIMIT + 1).into())],
)),
)
.unwrap();
loop {
if let Some((id, lines, picker)) = worker.poll() {
assert_eq!(id, 2);
assert_eq!(lines[0].0, 4);
assert!(lines[0].1.is_ok());
assert!(picker.unwrap().result.unwrap_err().contains("16 MiB"));
break;
}
thread::yield_now();
}
}
+242
View File
@@ -0,0 +1,242 @@
use super::*;
#[test]
fn native_definitions_preserve_snapshot_identity_and_utf16_until_navigation() {
let source = Source {
buffer_id: 7,
revision: 3,
row: 0,
column: 0,
request_id: 11,
};
let path = Path::new("/vamp-immutable-snapshot/Before/renamed/λ.rs");
let span = json!({
"start": {"line": 4, "character": 5},
"end": {"line": 4, "character": 8}
});
for location in [
json!({"uri": file_uri(path).unwrap(), "range": span}),
json!({"targetUri": file_uri(path).unwrap(),
"targetRange": span, "targetSelectionRange": span}),
] {
let effects = Runtime::definition_effects(&source, &location).unwrap();
let expected_target_range = location.get("targetUri").map(|_| Utf16Range {
start: (4, 5),
end: (4, 8),
});
assert!(matches!(
effects.as_slice(),
[UpdateEffect::NavigateUtf16 {
buffer_id: 7, revision: 3, path: target,
range: Utf16Range { start: (4, 5), end: (4, 8) }, target_range,
}] if target == path && *target_range == expected_target_range
));
}
for invalid in [
json!({"start": {"line": 4, "character": 8},
"end": {"line": 4, "character": 5}}),
json!({"start": {"line": -1, "character": 0},
"end": {"line": 0, "character": 0}}),
] {
for location in [
json!({"uri": file_uri(path).unwrap(), "range": invalid}),
json!({"targetUri": file_uri(path).unwrap(),
"targetRange": invalid, "targetSelectionRange": span}),
json!({"targetUri": file_uri(path).unwrap(),
"targetRange": span, "targetSelectionRange": invalid}),
] {
assert!(Runtime::definition_effects(&source, &location).is_err());
}
}
assert!(
serde_json::from_value::<UpdateEffect>(json!({
"type": "navigate_utf16", "buffer_id": 7, "revision": 3,
"path": path, "range": {"start": [4,5], "end": [4,8]},
"target_range": null
}))
.is_err()
);
}
#[test]
fn semantic_decoder_validates_raw_coordinates_before_grapheme_rounding() {
let options = semantic_options(Some(&json!({
"full": true,
"legend": {"tokenTypes": ["custom", "function"],
"tokenModifiers": ["custom", "readonly", "declaration", "async"]}
})))
.unwrap()
.unwrap();
let tokens = decode_semantic_tokens(
"A😀e\u{301}Z\r\n\r\nx",
&json!({"data": [
0, 1, 2, 1, 2,
0, 2, 1, 1, 12,
0, 1, 1, 1, 0,
0, 1, 999, 0, 0,
1, 0, 1, 1, 0,
1, 0, 0, 1, 0,
0, 0, 100, 1, 0
]}),
&options,
)
.unwrap()
.unwrap();
assert_eq!(tokens.len(), 4);
assert_eq!(tokens[0].start, TextPosition { row: 0, column: 1 });
assert_eq!(tokens[0].end, TextPosition { row: 0, column: 2 });
assert_eq!(tokens[0].modifiers, SemanticTokenModifier::Readonly as u16);
assert_eq!(tokens[1].start, TextPosition { row: 0, column: 2 });
assert_eq!(tokens[1].end, TextPosition { row: 0, column: 3 });
assert_eq!(
tokens[1].modifiers,
SemanticTokenModifier::Declaration as u16 | SemanticTokenModifier::Async as u16
);
assert_eq!(tokens[2].start, tokens[1].start);
assert_eq!(tokens[3].end, TextPosition { row: 2, column: 1 });
for data in [
json!([0, 0, 1, 1]),
json!([-1, 0, 1, 1, 0]),
json!([0, 0.5, 1, 1, 0]),
json!([0, 0, 1, 1, 2147483648u64]),
json!([0, 0, 1, 65536, 0]),
json!([0, 0, 1, 2, 0]),
json!([0, 0, 1, 1, 16]),
json!([0, 2, 1, 0, 0]),
json!([0, 1, 1, 1, 0]),
json!([0, 7, 1, 1, 0]),
json!([2, 0, 1, 1, 0]),
json!([0, 1, 2, 1, 0, 0, 1, 1, 1, 0]),
] {
assert!(
decode_semantic_tokens("A😀e\u{301}Z\n", &json!({"data": data}), &options).is_err(),
"{data}"
);
}
let count = MAX_BODY / std::mem::size_of::<SemanticToken>() + 1;
assert!(decode_semantic_tokens("x", &json!({"data": vec![0; count * 5]}), &options).is_err());
}
#[test]
fn unicode_positions_and_local_uri_boundaries() {
let text = "A😀e\u{301}Z\n";
let (start, end) = range(
text,
&json!({"start": {"line": 0, "character": 2}, "end": {"line": 0, "character": 4}}),
)
.unwrap();
assert_eq!(start, TextPosition { row: 0, column: 1 });
assert_eq!(end, TextPosition { row: 0, column: 3 });
assert_eq!(
outbound_position(text, 0, 3).unwrap(),
json!({"line": 0, "character": 5})
);
assert_eq!(
position(text, &json!({"line": 1, "character": 999}), false).unwrap(),
TextPosition { row: 1, column: 0 }
);
assert!(
range(
text,
&json!({"start": {"line": 0, "character": 3}, "end": {"line": 0, "character": 2}})
)
.is_err()
);
let path = Path::new("/tmp/source space λ😀.rs");
assert_eq!(uri_path(&file_uri(path).unwrap()).unwrap(), path);
assert_eq!(
uri_path("file://localhost/tmp/a%23b").unwrap(),
Path::new("/tmp/a#b")
);
for uri in [
"https://example.invalid/a",
"file://remote/tmp/a",
"file:///tmp/%",
"file:///tmp/%00",
"file:///tmp/%FF",
"file:///tmp/a?query",
] {
assert!(uri_path(uri).is_err(), "{uri}");
}
}
#[test]
fn hover_contents_keep_lines_and_reject_malformed_values() {
let source = "A😀e\u{301}Z\r\n";
for kind in ["plaintext", "markdown"] {
assert_eq!(
hover_text(
&json!({
"contents": {"kind": kind, "value": "😀e\u{301}\r\n\tcode\rnext\u{1b}\u{85}"},
"range": {"start": {"line": 0, "character": 2},
"end": {"line": 0, "character": 4}}
}),
source,
)
.unwrap(),
Some("😀e\u{301}\n code\nnext ".into()),
);
}
for contents in [
json!("line\ncode"),
json!({"language": "rust", "value": "line\ncode"}),
] {
assert_eq!(
hover_text(&json!({"contents": contents}), source).unwrap(),
Some("line\ncode".into()),
);
}
assert_eq!(
hover_text(
&json!({"contents": ["first", {"language": "rust", "value": "fn main() {\n}"}]}),
source,
)
.unwrap(),
Some("first\n\nfn main() {\n}".into()),
);
for value in [
Value::Null,
json!({"contents": ""}),
json!({"contents": []}),
] {
assert_eq!(hover_text(&value, source).unwrap(), None);
}
for value in [
json!({}),
json!({"contents": {"kind": "html", "value": "x"}}),
json!({"contents": {"language": "rust", "value": false}}),
json!({"contents": [{"kind": "markdown", "value": "x"}]}),
json!({"contents": "x", "range": {"start": {"line": 1, "character": 0},
"end": {"line": 0, "character": 0}}}),
json!({"contents": "\t".repeat(MAX_BODY / 4 + 1)}),
] {
assert!(hover_text(&value, source).is_err());
}
}
#[test]
fn completion_text_limit_counts_the_atomic_batch_not_sort_metadata() {
let source = Source {
buffer_id: 1,
revision: 0,
row: 0,
column: 0,
request_id: 1,
};
let at_limit = json!([{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)}]);
assert_eq!(
decode_completion("", &at_limit, &source, None).unwrap()[0].edits[0]
.text
.len(),
MAX_BODY - 1
);
let over_limit = json!([
{"label": "x", "insertText": "x".repeat(MAX_BODY - 1)},
{"label": "y"}
]);
assert!(
decode_completion("", &over_limit, &source, None)
.unwrap_err()
.contains("4 MiB")
);
}
+143
View File
@@ -0,0 +1,143 @@
use super::*;
fn at(text: &str, needle: &str) -> Result<LinkTarget, String> {
let byte = text.find(needle).unwrap();
let prefix = &text[..byte];
let row = prefix.bytes().filter(|byte| *byte == b'\n').count();
let column = prefix.rsplit('\n').next().unwrap().graphemes(true).count();
link_at(text, TextPosition { row, column })
}
fn target(path: Option<&str>, fragment: Option<&str>) -> LinkTarget {
LinkTarget {
path: path.map(PathBuf::from),
fragment: fragment.map(str::to_owned),
}
}
#[test]
fn follows_inline_labels_destinations_and_grapheme_columns() {
let text = "👩‍💻 [go **there**](<../My%20Notes/%E6%97%A5%E6%9C%AC\\(1\\).md#caf%C3%A9>)";
let expected = target(Some("../My Notes/日本(1).md"), Some("café"));
assert_eq!(at(text, "there").unwrap(), expected);
assert_eq!(at(text, "My%20").unwrap(), expected);
assert_eq!(
at("[absolute](/Tmp/Note.md)", "absolute").unwrap(),
target(Some("/Tmp/Note.md"), None)
);
assert_eq!(
at("[here](#title)", "here").unwrap(),
target(None, Some("title"))
);
assert_eq!(at("[top]()", "top").unwrap(), target(None, None));
assert_eq!(
at("[here](#title)", "[here").unwrap(),
target(None, Some("title"))
);
assert_eq!(
at("[file](literal%23name.md)", "file").unwrap(),
target(Some("literal#name.md"), None)
);
assert_eq!(
at("[file](My\\[Notes\\].md)", "file").unwrap(),
target(Some("My[Notes].md"), None)
);
}
#[test]
fn resolves_all_reference_forms_and_multiline_block_definitions() {
let text = "[full][ A B ] [a b][] [A B]\n\n> [a b]:\n> <My%20Notes.md#title>\n> \"A title\"\n\n[a b]: ignored.md\n";
let expected = target(Some("My Notes.md"), Some("title"));
for needle in ["full", " A B ", "a b][]", "A B]"] {
assert_eq!(at(text, needle).unwrap(), expected);
}
assert_eq!(
at("[Straße]\n\n[STRASSE]: file.md", "Straße").unwrap(),
target(Some("file.md"), None)
);
let text = "> [multi\n> line][target]\n\n[target]: other.md\n";
assert_eq!(at(text, "line]").unwrap(), target(Some("other.md"), None));
}
#[test]
fn rejects_images_code_plaintext_and_external_urls() {
for (text, needle) in [
("![image](file.md)", "image"),
("`[code](file.md)`", "code"),
("```md\n[code](file.md)\n```", "file"),
(" [code](file.md)", "code"),
("plain file.md text", "file"),
("[web](https://example.com)", "web"),
("[web](//example.com/a)", "web"),
("[web](mailto:a@example.com)", "web"),
("<https://example.com>", "example"),
] {
assert!(at(text, needle).is_err(), "{text}");
}
assert!(
at("[missing]", "missing")
.unwrap_err()
.contains("not defined")
);
assert!(
at("[bad](file%ZZ.md)", "bad")
.unwrap_err()
.contains("percent")
);
assert!(
at("[bad](file%FF.md)", "bad")
.unwrap_err()
.contains("UTF-8")
);
assert!(at("[bad](#)", "bad").unwrap_err().contains("empty"));
}
#[test]
fn locates_visible_unicode_headings_setext_and_unique_slugs() {
let text = "# **Café** [日本](destination.md) `code` ###\n\n> ## Café 日本 code\n\nCafé 日本 code\n====\n\n# Café 日本 code-1\n\n# Cafe\u{301} _name_ ![alt](hidden.md)\n\n```md\n# Hidden\n```\n";
assert_eq!(heading_row(text, "café-日本-code").unwrap(), 0);
assert_eq!(heading_row(text, "café-日本-code-1").unwrap(), 2);
assert_eq!(heading_row(text, "café-日本-code-2").unwrap(), 4);
assert_eq!(heading_row(text, "café-日本-code-1-1").unwrap(), 7);
assert_eq!(heading_row(text, "cafe\u{301}-name-alt").unwrap(), 9);
assert!(heading_row(text, "hidden").is_err());
assert!(heading_row(text, "destinationmd").is_err());
assert!(heading_row(text, "").is_err());
assert_eq!(
heading_row("# [Heading][ref]\n\n[ref]: ignored.md", "heading").unwrap(),
0
);
assert_eq!(
heading_row("# ![Alt][ref]\n\n[ref]: ignored.md", "alt").unwrap(),
0
);
assert_eq!(heading_row("# ![Alt][missing]", "altmissing").unwrap(), 0);
}
#[test]
fn renders_entities_or_reports_the_explicit_entity_ceiling() {
assert_eq!(
at("[file](a&amp;b&#x20;c.md)", "file").unwrap(),
target(Some("a&b c.md"), None)
);
assert_eq!(
at("[file](&lt;name&gt;)", "file").unwrap(),
target(Some("<name>"), None)
);
assert!(
at("[file](a&copy;b.md)", "file")
.unwrap_err()
.contains("Unsupported Markdown HTML entity")
);
assert_eq!(heading_row("# A &amp; B &#xE9;\n", "a--b-é").unwrap(), 0);
assert!(
heading_row("# &copy;\n", "copy")
.unwrap_err()
.contains("Unsupported Markdown HTML entity")
);
assert_eq!(heading_row("# &copy;\n# Valid\n", "valid").unwrap(), 1);
assert_eq!(
heading_row("> First\n> Second\n> ------\n", "first-second").unwrap(),
0
);
}
+123
View File
@@ -0,0 +1,123 @@
use super::*;
fn rect(x: u16, y: u16, width: u16, height: u16) -> PaneRect {
PaneRect {
x,
y,
width,
height,
}
}
fn leaves(tree: &PaneTree, bounds: PaneRect) -> Vec<(usize, PaneRect)> {
let mut leaves = Vec::new();
tree.visit_rects(bounds, |id, rect| leaves.push((id, rect)));
leaves
}
#[test]
fn nested_layout_partitions_bounds_and_preserves_ids() {
let bounds = rect(3, 5, 11, 9);
let mut tree = PaneTree::new(10);
assert!(tree.split(10, 20, SplitAxis::Vertical));
assert!(tree.split(20, 30, SplitAxis::Horizontal));
assert!(!tree.split(99, 40, SplitAxis::Horizontal));
assert!(!tree.split(10, 30, SplitAxis::Horizontal));
assert!(!tree.split(10, 10, SplitAxis::Horizontal));
assert_eq!(tree.len(), 3);
let panes = leaves(&tree, bounds);
assert_eq!(
panes,
vec![
(10, rect(3, 5, 5, 9)),
(20, rect(8, 5, 6, 4)),
(30, rect(8, 9, 6, 5))
]
);
let mut cells = std::collections::HashSet::new();
for (_, pane) in panes {
for y in pane.y..pane.y + pane.height {
for x in pane.x..pane.x + pane.width {
assert!(cells.insert((x, y)), "overlapping pane cell");
}
}
}
assert_eq!(
cells.len(),
usize::from(bounds.width) * usize::from(bounds.height)
);
assert!(leaves(&tree, rect(0, 0, 0, 10)).is_empty());
assert!(leaves(&tree, rect(0, 0, 10, 0)).is_empty());
}
#[test]
fn closing_expands_nested_sibling_and_refuses_last() {
let bounds = rect(0, 0, 20, 12);
let mut tree = PaneTree::new(1);
tree.split(1, 2, SplitAxis::Vertical);
tree.split(2, 3, SplitAxis::Horizontal);
assert_eq!(tree.close(1), Some(2));
assert_eq!(
leaves(&tree, bounds),
vec![(2, rect(0, 0, 20, 6)), (3, rect(0, 6, 20, 6))]
);
assert_eq!(tree.close(99), None);
assert_eq!(tree.close(2), Some(3));
assert_eq!(leaves(&tree, bounds), vec![(3, bounds)]);
assert_eq!(tree.close(3), None);
assert_eq!(tree.len(), 1);
assert!(!tree.contains(1));
assert!(tree.contains(3));
}
#[test]
fn directional_focus_handles_unequal_nested_regions() {
let bounds = rect(0, 0, 20, 12);
let mut tree = PaneTree::new(1);
tree.split(1, 2, SplitAxis::Vertical);
tree.split(2, 3, SplitAxis::Horizontal);
tree.split(3, 4, SplitAxis::Horizontal);
assert_eq!(tree.neighbor(1, PaneDirection::Right, bounds), Some(3));
assert_eq!(tree.neighbor(2, PaneDirection::Down, bounds), Some(3));
assert_eq!(tree.neighbor(3, PaneDirection::Down, bounds), Some(4));
assert_eq!(tree.neighbor(4, PaneDirection::Up, bounds), Some(3));
assert_eq!(tree.neighbor(4, PaneDirection::Left, bounds), Some(1));
assert_eq!(tree.neighbor(1, PaneDirection::Left, bounds), None);
assert_eq!(tree.neighbor(99, PaneDirection::Right, bounds), None);
assert_eq!(
tree.neighbor(1, PaneDirection::Right, rect(0, 0, 0, 0)),
None
);
}
#[test]
fn navigation_prefers_overlap_then_edge_and_breaks_ties_by_id() {
let current = rect(10, 10, 10, 10);
let candidates = [
(1, rect(20, 0, 5, 5)),
(2, rect(25, 10, 5, 10)),
(4, rect(22, 10, 5, 10)),
(3, rect(22, 10, 5, 10)),
(0, rect(20, 10, 0, 10)),
];
assert_eq!(
directional_neighbor(current, PaneDirection::Right, candidates.into_iter()),
Some(3)
);
assert_eq!(
directional_neighbor(current, PaneDirection::Right, candidates.into_iter().rev()),
Some(3)
);
assert_eq!(
directional_neighbor(
current,
PaneDirection::Up,
[(7, rect(10, 0, 10, 5))].into_iter()
),
Some(7)
);
assert_eq!(
directional_neighbor(current, PaneDirection::Left, candidates.into_iter()),
None
);
}
+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);
}
+503
View File
@@ -0,0 +1,503 @@
fn working_file_changes(
root: &Path,
buffers: &[(PathBuf, Arc<str>)],
cancelled: &dyn Fn() -> bool,
) -> Result<Vec<WorkingFileChange>, String> {
WorkingFileReader::default().read(root, buffers, cancelled)
}
use super::*;
fn fixture_command(root: &Path, program: &str, args: &[&str]) -> String {
let mut command = Command::new(program);
if program == "jj" {
command.args([
"--config=user.name=\"Picker Author\"",
"--config=user.email=\"picker@example.test\"",
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
]);
}
let output = command
.args(args)
.current_dir(root)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
.env("JJ_CONFIG", "/dev/null")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().into()
}
fn picker_git_fixture() -> Temporary {
let temp = Temporary::new().unwrap();
fixture_command(&temp.0, "git", &["init", "--quiet", "--template="]);
fixture_command(&temp.0, "git", &["config", "user.name", "Picker Author"]);
fixture_command(
&temp.0,
"git",
&["config", "user.email", "picker@example.test"],
);
temp
}
fn changes_by_path(
root: &Path,
buffers: &[(PathBuf, Arc<str>)],
) -> BTreeMap<PathBuf, WorkingFileChange> {
working_file_changes(root, buffers, &|| false)
.unwrap()
.into_iter()
.map(|change| (change.path.clone(), change))
.collect()
}
#[test]
fn working_git_changes_cover_disk_index_buffers_binary_exact_renames_and_read_only_queries() {
use std::os::unix::{ffi::OsStringExt, fs::PermissionsExt};
let temp = picker_git_fixture();
let root = &temp.0;
for (name, contents) in [
(".gitignore", "ignored*\n"),
("modified", "original\r\n"),
("unchanged", "same\n"),
("removed", "deleted\n"),
("old", "rename content\n"),
("duplicate-one", "duplicate\n"),
("duplicate-two", "duplicate\n"),
("mode", "mode\n"),
("binary", "before\0binary"),
] {
fs::write(root.join(name), contents).unwrap();
}
let non_utf8 = PathBuf::from(OsString::from_vec(b"non-utf8-\xff".to_vec()));
let non_utf8 = match fs::write(root.join(&non_utf8), "before\n") {
Ok(()) => Some(non_utf8),
Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => None,
Err(error) => panic!("cannot create byte filename: {error}"),
};
fixture_command(root, "git", &["add", "--all"]);
fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]);
fs::write(root.join("modified"), "staged\n").unwrap();
fixture_command(root, "git", &["add", "--", "modified"]);
fs::write(root.join("modified"), "unstaged\n").unwrap();
fs::write(root.join("binary"), "after\0binary").unwrap();
if let Some(name) = &non_utf8 {
fs::write(root.join(name), "after\n").unwrap();
}
fs::remove_file(root.join("removed")).unwrap();
let renamed = "-new [file]\n\t\".txt";
fs::rename(root.join("old"), root.join(renamed)).unwrap();
fs::remove_file(root.join("duplicate-one")).unwrap();
fs::remove_file(root.join("duplicate-two")).unwrap();
fs::write(root.join("new-duplicate-one"), "duplicate\n").unwrap();
fs::write(root.join("new-duplicate-two"), "duplicate\n").unwrap();
fs::set_permissions(root.join("mode"), fs::Permissions::from_mode(0o755)).unwrap();
fs::write(root.join("added"), "added\n").unwrap();
fs::write(root.join("ignored"), "ignored\n").unwrap();
fs::write(root.join("ignored-tracked"), "forced\n").unwrap();
fixture_command(root, "git", &["add", "-f", "--", "ignored-tracked"]);
std::os::unix::fs::symlink(root.join("modified"), root.join("link")).unwrap();
let index = fs::read(root.join(".git/index")).unwrap();
let head = fs::read(root.join(".git/HEAD")).unwrap();
let disk = changes_by_path(root, &[]);
for name in ["modified", "binary", "mode"] {
assert_eq!(disk[Path::new(name)].status, FileStatus::Modified);
}
if let Some(name) = &non_utf8 {
assert_eq!(disk[name].status, FileStatus::Modified);
}
assert_eq!(disk[Path::new("removed")].status, FileStatus::Removed);
assert_eq!(disk[Path::new(renamed)].status, FileStatus::Renamed);
assert_eq!(
disk[Path::new(renamed)].old_path.as_deref(),
Some(Path::new("old"))
);
for name in [
"added",
"ignored-tracked",
"new-duplicate-one",
"new-duplicate-two",
] {
assert_eq!(disk[Path::new(name)].status, FileStatus::Added);
}
for name in ["duplicate-one", "duplicate-two"] {
assert_eq!(disk[Path::new(name)].status, FileStatus::Removed);
}
for name in ["unchanged", "old", "ignored", "link"] {
assert!(!disk.contains_key(Path::new(name)), "{name}");
}
let overlays = changes_by_path(
root,
&[
(root.join("modified"), "original\n".into()),
(root.join("unchanged"), "dirty\n".into()),
(root.join("removed"), "deleted\n".into()),
(root.join(renamed), "dirty renamed\n".into()),
(root.join("mode"), "mode\n".into()),
(root.join("ignored-buffer"), "ignored\n".into()),
],
);
assert!(!overlays.contains_key(Path::new("modified")));
assert!(!overlays.contains_key(Path::new("ignored-buffer")));
assert_eq!(
overlays[Path::new("unchanged")].status,
FileStatus::Modified
);
assert_eq!(overlays[Path::new("removed")].status, FileStatus::Removed);
assert_eq!(overlays[Path::new(renamed)].status, FileStatus::Renamed);
assert_eq!(overlays[Path::new("mode")].status, FileStatus::Modified);
let aliases = Temporary::new().unwrap();
let alias = aliases.0.join("checkout");
std::os::unix::fs::symlink(root, &alias).unwrap();
let aliased = changes_by_path(root, &[(alias.join("unchanged"), "dirty\n".into())]);
assert_eq!(aliased[Path::new("unchanged")].status, FileStatus::Modified);
fixture_command(root, "git", &["config", "core.filemode", "0"]);
assert!(
!changes_by_path(root, &[(root.join("mode"), "mode\n".into())])
.contains_key(Path::new("mode"))
);
fixture_command(root, "git", &["config", "core.autocrlf", "1"]);
fs::write(root.join("unchanged"), "same\r\n").unwrap();
assert!(!changes_by_path(root, &[]).contains_key(Path::new("unchanged")));
fixture_command(root, "git", &["config", "--unset", "core.autocrlf"]);
fixture_command(root, "git", &["config", "core.filemode", "true"]);
fs::write(root.join("unchanged"), "same\n").unwrap();
assert_eq!(fs::read(root.join(".git/index")).unwrap(), index);
assert_eq!(fs::read(root.join(".git/HEAD")).unwrap(), head);
assert_eq!(
fs::read_to_string(root.join("modified")).unwrap(),
"unstaged\n"
);
assert!(!root.join("removed").exists());
}
#[test]
fn working_git_subdirectory_cross_boundary_moves_worktrees_and_unsafe_roots() {
let temp = picker_git_fixture();
let root = &temp.0;
fs::create_dir(root.join("sub")).unwrap();
fs::write(root.join("sub/outgoing"), "out\n").unwrap();
fs::write(root.join("incoming"), "in\n").unwrap();
fixture_command(root, "git", &["add", "--all"]);
fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]);
fs::rename(root.join("sub/outgoing"), root.join("outgoing")).unwrap();
fs::rename(root.join("incoming"), root.join("sub/incoming")).unwrap();
let changes = changes_by_path(&root.join("sub"), &[]);
assert_eq!(changes.len(), 2);
assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Removed);
assert_eq!(changes[Path::new("incoming")].status, FileStatus::Added);
assert_eq!(changes[Path::new("incoming")].old_path, None);
std::os::unix::fs::symlink(root.join("sub"), root.join("linked")).unwrap();
assert!(changes_by_path(&root.join("linked"), &[]).is_empty());
assert!(changes_by_path(&root.join(".git"), &[]).is_empty());
let unrelated = Temporary::new().unwrap();
assert!(changes_by_path(&unrelated.0, &[]).is_empty());
let worktree = temp.0.join("worktree");
fixture_command(
root,
"git",
&["worktree", "add", "--quiet", "--detach", "worktree", "HEAD"],
);
fs::write(worktree.join("sub/outgoing"), "worktree edit\n").unwrap();
let changes = changes_by_path(&worktree.join("sub"), &[]);
assert_eq!(changes[Path::new("outgoing")].status, FileStatus::Modified);
}
#[test]
fn working_unborn_git_never_runs_filters_external_diff_textconv_or_hooks() {
let temp = picker_git_fixture();
let root = &temp.0;
fs::write(root.join(".gitignore"), "ignored\n").unwrap();
fs::write(root.join("tracked"), "tracked\n").unwrap();
fs::write(root.join("untracked"), "untracked\n").unwrap();
fs::write(root.join("ignored"), "ignored\n").unwrap();
fixture_command(root, "git", &["add", "--", ".gitignore", "tracked"]);
let changes = changes_by_path(root, &[(root.join("named"), "not saved\n".into())]);
for name in [".gitignore", "tracked", "untracked", "named"] {
assert_eq!(changes[Path::new(name)].status, FileStatus::Added);
}
assert!(!changes.contains_key(Path::new("ignored")));
assert!(!root.join("named").exists());
fixture_command(root, "git", &["commit", "--quiet", "-m", "Baseline"]);
fs::write(
root.join(".gitattributes"),
"tracked filter=evil diff=evil\n",
)
.unwrap();
let evil = "sh -c 'touch executed; cat'";
for key in [
"filter.evil.clean",
"filter.evil.process",
"diff.evil.command",
"diff.evil.textconv",
] {
fixture_command(root, "git", &["config", key, evil]);
}
fixture_command(root, "git", &["config", "filter.evil.required", "true"]);
fs::write(root.join("tracked"), "changed\n").unwrap();
assert_eq!(
changes_by_path(root, &[])[Path::new("tracked")].status,
FileStatus::Modified
);
assert_eq!(
changes_by_path(root, &[(root.join("tracked"), "tracked\n".into())])
.get(Path::new("tracked")),
None
);
assert!(!root.join("executed").exists());
assert!(
working_file_changes(root, &[], &|| true)
.unwrap_err()
.contains("cancelled")
);
assert!(
working_file_changes(
root,
&[(root.join("tracked"), "x".repeat(FILE_LIMIT + 1).into())],
&|| false
)
.unwrap_err()
.contains("16 MiB")
);
}
#[test]
fn working_jj_root_parent_colocation_and_merge_queries_do_not_snapshot() {
for colocate in ["--colocate", "--no-colocate"] {
let temp = Temporary::new().unwrap();
let root = &temp.0;
fixture_command(root, "jj", &["git", "init", colocate, "."]);
fs::write(root.join("file"), "baseline\n").unwrap();
assert_eq!(
changes_by_path(root, &[])[Path::new("file")].status,
FileStatus::Added
);
fs::write(root.join(".gitignore"), "ignored\n").unwrap();
fixture_command(root, "jj", &["describe", "-m", "Baseline"]);
fixture_command(root, "jj", &["new"]);
let operation = fixture_command(
root,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id",
],
);
let parent = fixture_command(
root,
"jj",
&[
"--ignore-working-copy",
"log",
"--no-graph",
"-r",
"@-",
"-T",
"commit_id",
],
);
let store = store_for_jj(root).unwrap();
let head = fs::read(store.join("HEAD")).unwrap();
let index = fs::read(store.join("index")).ok();
fs::write(root.join("file"), "disk\n").unwrap();
fs::write(root.join("new"), "new\n").unwrap();
fs::write(root.join("ignored"), "ignored\n").unwrap();
let changes = changes_by_path(root, &[]);
assert_eq!(changes[Path::new("file")].status, FileStatus::Modified);
assert_eq!(changes[Path::new("new")].status, FileStatus::Added);
assert!(!changes.contains_key(Path::new("ignored")));
assert!(
!changes_by_path(root, &[(root.join("file"), "baseline\n".into())])
.contains_key(Path::new("file"))
);
assert_eq!(fs::read(store.join("HEAD")).unwrap(), head);
assert_eq!(fs::read(store.join("index")).ok(), index);
assert_eq!(
fixture_command(
root,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id"
]
),
operation
);
fixture_command(root, "jj", &["new", "root()"]);
fs::write(root.join("sibling"), "sibling\n").unwrap();
fixture_command(root, "jj", &["describe", "-m", "Sibling"]);
fixture_command(root, "jj", &["new", &parent, "@"]);
assert!(changes_by_path(root, &[]).is_empty());
}
}
#[test]
fn working_jj_conflicted_parent_uses_cli_projection_without_snapshotting() {
for colocate in ["--colocate", "--no-colocate"] {
let temp = Temporary::new().unwrap();
let root = &temp.0;
fixture_command(root, "jj", &["git", "init", colocate, "."]);
fs::write(root.join("conflicted"), "base\n").unwrap();
fixture_command(root, "jj", &["describe", "-m", "Base"]);
let base = fixture_command(
root,
"jj",
&[
"--ignore-working-copy",
"log",
"--no-graph",
"-r",
"@",
"-T",
"commit_id",
],
);
fixture_command(root, "jj", &["new"]);
fs::write(root.join("conflicted"), "left\n").unwrap();
fixture_command(root, "jj", &["describe", "-m", "Left"]);
let left = fixture_command(
root,
"jj",
&[
"--ignore-working-copy",
"log",
"--no-graph",
"-r",
"@",
"-T",
"commit_id",
],
);
fixture_command(root, "jj", &["new", &base]);
fs::write(root.join("conflicted"), "right\n").unwrap();
fixture_command(root, "jj", &["describe", "-m", "Right"]);
let right = fixture_command(
root,
"jj",
&[
"--ignore-working-copy",
"log",
"--no-graph",
"-r",
"@",
"-T",
"commit_id",
],
);
fixture_command(root, "jj", &["new", &left, &right]);
fixture_command(root, "jj", &["new"]);
let projected = fs::read_to_string(root.join("conflicted")).unwrap();
assert!(projected.contains("<<<<<<<"));
let operation = fixture_command(
root,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id",
],
);
let mut reader = WorkingFileReader::default();
assert!(reader.read(root, &[], &|| false).unwrap().is_empty());
fs::write(root.join("conflicted"), "resolved in working file\n").unwrap();
let changes = reader.read(root, &[], &|| false).unwrap();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].path, Path::new("conflicted"));
assert_eq!(changes[0].status, FileStatus::Modified);
assert!(
reader
.read(
root,
&[(root.join("conflicted"), projected.into())],
&|| false
)
.unwrap()
.is_empty()
);
assert_eq!(
fs::read_to_string(root.join("conflicted")).unwrap(),
"resolved in working file\n"
);
assert_eq!(
fixture_command(
root,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id"
]
),
operation
);
}
}
#[test]
fn cancellation_kills_pipe_holding_children_and_drains_both_streams() {
let temp = Temporary::new().unwrap();
let ready = temp.0.join("ready");
let cancelled = Arc::new(AtomicBool::new(false));
let worker_cancelled = Arc::clone(&cancelled);
let gate = ready.clone();
let (sent, received) = mpsc::channel();
let worker = thread::spawn(move || {
let mut command = Command::new("sh");
command
.args([
"-c",
"sleep 600 & printf ready > \"$1\"; wait",
"vamp-diff-test",
])
.arg(gate);
let result = run(command, vec![], &worker_cancelled).map(|_| ());
sent.send(result).unwrap();
});
// File handshake, not a wall-clock scheduling assumption.
while !ready.exists() {
if let Ok(result) = received.try_recv() {
panic!("Process exited before the child was ready: {result:?}");
}
thread::yield_now();
}
cancelled.store(true, Ordering::Release);
assert!(received.recv().unwrap().unwrap_err().contains("cancelled"));
worker.join().unwrap();
let mut command = Command::new("sh");
command.args(["-c", "i=0; while [ \"$i\" -lt 10000 ]; do printf 'line\\n'; printf 'error\\n' >&2; i=$((i+1)); done"]);
let result = run(command, vec![], &AtomicBool::new(false)).unwrap();
assert_eq!(result.code, Some(0));
assert_eq!(result.stdout.len(), 50_000);
assert_eq!(result.stderr.len(), 60_000);
}
+325
View File
@@ -0,0 +1,325 @@
use super::*;
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"vamp-resolver-test-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&path).unwrap();
Self(path)
}
fn jj(&self, args: &[&str]) -> String {
let output = Command::new("jj")
.current_dir(&self.0)
.env("JJ_CONFIG", "/dev/null")
.args([
"--config=user.name=Resolver",
"--config=user.email=resolver@example.test",
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
])
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap()
}
fn conflict(&self) {
self.jj(&["git", "init", "--no-colocate", "."]);
self.jj(&["config", "set", "--repo", "user.name", "Resolver"]);
self.jj(&[
"config",
"set",
"--repo",
"user.email",
"resolver@example.test",
]);
self.jj(&[
"config",
"set",
"--repo",
"debug.commit-timestamp",
"\"2001-01-01T00:00:00Z\"",
]);
self.jj(&[
"config",
"set",
"--repo",
"debug.operation-timestamp",
"\"2001-01-01T00:00:00Z\"",
]);
fs::write(self.0.join("file"), "base\n").unwrap();
self.jj(&["bookmark", "create", "base"]);
self.jj(&["new", "base"]);
fs::write(self.0.join("file"), "left\n").unwrap();
self.jj(&["bookmark", "create", "left"]);
self.jj(&["new", "base"]);
fs::write(self.0.join("file"), "right\n").unwrap();
self.jj(&["bookmark", "create", "right"]);
self.jj(&["new", "left", "right"]);
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn discovery_is_readonly_and_save_uses_jj_authority_and_stale_guards() {
let fixture = Fixture::new();
fixture.conflict();
let operation = fixture.jj(&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id",
]);
let workspace = load(&fixture.0, &|| false).unwrap();
assert_eq!(workspace.files.len(), 1);
let file = &workspace.files[0];
assert!(file.sources.iter().any(|s| s.base && s.bytes == b"base\n"));
assert!(file.sources.iter().any(|s| !s.base && s.bytes == b"left\n"));
assert!(
file.sources
.iter()
.any(|s| !s.base && s.bytes == b"right\n")
);
for bookmark in ["base", "left", "right"] {
assert!(
file.sources
.iter()
.any(|s| s.label.contains(&format!("[{bookmark}]")))
);
}
assert_eq!(
operation,
fixture.jj(&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id"
])
);
let blocks = regions(file.result.as_ref().unwrap());
assert_eq!(blocks.len(), 1);
for source in &file.sources {
assert_eq!(blocks[0].choose(source).unwrap().as_bytes(), source.bytes);
}
let mut changed = blocks[0].clone();
changed.sides.pop();
assert!(changed.choose(&file.sources[0]).is_err());
fs::write(fixture.0.join("file"), "external\n").unwrap();
assert!(
save(&workspace, 0, Resolution::Text("lost\n".into()), &|| false)
.unwrap_err()
.contains("externally")
);
fs::write(fixture.0.join("file"), file.result.as_ref().unwrap()).unwrap();
fixture.jj(&["new", "base"]);
let before = fs::read(fixture.0.join("file")).unwrap();
assert!(
save(
&workspace,
0,
Resolution::Text("wrong checkout\n".into()),
&|| false
)
.unwrap_err()
.contains("checkout changed")
);
assert_eq!(fs::read(fixture.0.join("file")).unwrap(), before);
}
#[test]
fn text_choice_is_confirmed_by_jj() {
let fixture = Fixture::new();
fixture.conflict();
let mut workspace = load(&fixture.0, &|| false).unwrap();
let cancelled_after_write =
|| fs::read(fixture.0.join("file")).is_ok_and(|bytes| bytes == b"draft\n");
let saved = save(
&workspace,
0,
Resolution::Text("draft\n".into()),
&cancelled_after_write,
)
.unwrap();
assert_eq!(saved.file.status, Status::Unconfirmed);
assert!(saved.confirmation.is_some());
assert_eq!(fs::read(fixture.0.join("file")).unwrap(), b"draft\n");
assert_eq!(
entries(&fixture.0, &workspace.checkout.commit, &|| false).unwrap(),
[PathBuf::from("file")]
);
workspace.checkout = saved.checkout;
workspace.files[0] = saved.file;
let outcome = save(
&workspace,
0,
Resolution::Text("resolved\n".into()),
&|| false,
)
.unwrap();
assert_eq!(
outcome.file.status,
Status::Resolved,
"{:?}",
outcome.confirmation
);
assert_eq!(fs::read(fixture.0.join("file")).unwrap(), b"resolved\n");
assert!(
entries(&fixture.0, &outcome.checkout.commit, &|| false)
.unwrap()
.is_empty()
);
}
#[test]
fn all_three_sides_and_bases_are_loaded_and_directory_children_are_protected() {
let fixture = Fixture::new();
fixture.conflict();
fixture.jj(&["new", "base"]);
fs::write(fixture.0.join("file"), "third\n").unwrap();
fixture.jj(&["bookmark", "create", "third"]);
fixture.jj(&["new", "left", "right", "third"]);
let workspace = load(&fixture.0, &|| false).unwrap();
let file = &workspace.files[0];
assert_eq!(file.sources.iter().filter(|s| !s.base).count(), 3);
assert_eq!(file.sources.iter().filter(|s| s.base).count(), 2);
assert!(file.sources.iter().any(|s| s.bytes == b"third\n"));
let blocks = regions(file.result.as_ref().unwrap());
for source in &file.sources {
assert_eq!(blocks[0].choose(source).unwrap().as_bytes(), source.bytes);
}
fs::remove_file(fixture.0.join("file")).unwrap();
fs::create_dir(fixture.0.join("file")).unwrap();
fs::write(fixture.0.join("file/untracked"), b"keep my draft").unwrap();
let workspace = load(&fixture.0, &|| false).unwrap();
assert!(
save(&workspace, 0, Resolution::Source(0), &|| false)
.unwrap_err()
.contains("untracked or modified children")
);
assert_eq!(
fs::read(fixture.0.join("file/untracked")).unwrap(),
b"keep my draft"
);
}
#[test]
fn binary_symlink_and_deleted_file_choices_preserve_types_and_bytes() {
let fixture = Fixture::new();
fixture.conflict();
fixture.jj(&["edit", "left"]);
fs::write(fixture.0.join("binary"), b"left\0\xff").unwrap();
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(fixture.0.join("binary"), fs::Permissions::from_mode(0o755)).unwrap();
}
std::os::unix::fs::symlink("left-target", fixture.0.join("link")).unwrap();
fixture.jj(&["new", "right"]);
fs::write(fixture.0.join("binary"), b"right\0\xfe").unwrap();
std::os::unix::fs::symlink("right-target", fixture.0.join("link")).unwrap();
fixture.jj(&["bookmark", "create", "right-extra"]);
fixture.jj(&["new", "left", "right-extra"]);
let mut workspace = load(&fixture.0, &|| false).unwrap();
for (name, kind, bytes) in [
("binary", FileKind::Binary, b"left\0\xff".as_slice()),
("link", FileKind::Symlink, b"left-target".as_slice()),
] {
let file = workspace
.files
.iter()
.position(|f| f.path == Path::new(name))
.unwrap();
assert!(workspace.files[file].result.is_none());
let source = workspace.files[file]
.sources
.iter()
.position(|s| !s.base && s.kind == kind && s.bytes == bytes)
.unwrap();
assert!(
save(
&workspace,
file,
Resolution::Text("not allowed".into()),
&|| false
)
.is_err()
);
let outcome = save(&workspace, file, Resolution::Source(source), &|| false).unwrap();
assert_eq!(
outcome.file.status,
Status::Resolved,
"{:?}",
outcome.confirmation
);
workspace.checkout = outcome.checkout;
workspace.files[file] = outcome.file;
if kind == FileKind::Symlink {
assert_eq!(
fs::read_link(fixture.0.join(name)).unwrap(),
Path::new("left-target")
);
} else {
assert_eq!(fs::read(fixture.0.join(name)).unwrap(), bytes);
}
if kind == FileKind::Binary {
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(fixture.0.join(name))
.unwrap()
.permissions()
.mode()
& 0o111,
0o111
);
}
}
// add/add conflicts retain an absent base as an explicit deletion choice.
let file = workspace
.files
.iter()
.position(|f| f.path == Path::new("binary"))
.unwrap();
let source = workspace.files[file]
.sources
.iter()
.position(|s| s.kind == FileKind::Deleted)
.unwrap();
let outcome = save(&workspace, file, Resolution::Source(source), &|| false).unwrap();
assert_eq!(
outcome.file.status,
Status::Resolved,
"{:?}",
outcome.confirmation
);
assert!(!fixture.0.join("binary").exists());
}
#[test]
fn marker_styles_widths_multisides_and_missing_eol() {
let snapshot = "before\n<<<<<<<<<<< conflict 1 of 1\n+++++++++++ a\nleft\n----------- base\nold\n+++++++++++ b\nright\n----------- base2\nold2\n+++++++++++ c\nthird\n>>>>>>>>>>> conflict 1 of 1 ends\nafter\n";
let parsed = regions(snapshot);
assert_eq!(parsed[0].lines, 1..13);
assert_eq!(parsed[0].sides, ["left\n", "right\n", "third\n"]);
assert_eq!(parsed[0].bases, ["old\n", "old2\n"]);
let prefixed = format!("<<<<<<< literal marker\n{snapshot}");
assert_eq!(regions(&prefixed).len(), 1);
assert_eq!(regions(&prefixed)[0].lines, 2..14);
let diff = "<<<<<<< conflict 1 of 1\n%%%%%%% diff from: base\n^^^^^^^ to: left\n-old\n+left\n+++++++ right\nright\n>>>>>>> conflict 1 of 1 ends\n";
assert_eq!(regions(diff)[0].sides, ["left\n", "right\n"]);
let git = "<<<<<<< left\nleft\n||||||| base\nold\n=======\nright\n>>>>>>> right";
assert_eq!(regions(git)[0].sides, ["left", "right"]);
assert!(regions("<<<<<<< unfinished\nleft\n").is_empty());
}
+143
View File
@@ -0,0 +1,143 @@
use super::*;
fn test_sources(sides: usize) -> Vec<Source> {
(0..sides * 2 - 1)
.map(|index| Source {
label: format!("source {index}"),
base: index % 2 == 1,
kind: FileKind::Text,
bytes: b"historical full file; must never replace working text\n".to_vec(),
tree_id: String::new(),
term: index / 2,
sides,
bases: sides - 1,
node: Node::Absent,
})
.collect()
}
fn snapshot(left: &str, base: &str, right: &str) -> String {
format!(
"<<<<<<< conflict\n+++++++ side 1\n{left}------- base\n{base}+++++++ side 2\n{right}>>>>>>> conflict ends\n"
)
}
#[test]
fn current_working_projections_keep_partial_resolutions_and_multiple_regions() {
let sources = test_sources(2);
let raw = format!(
"unsaved heading\nalready manually resolved\n{}auto-merged middle\n<<<<<<< conflict\n%%%%%%% Changes from base to side 1\n\\\\\\\\\\\\\\ continuation\n-old two\n+left two\n+++++++ side 2\nright two\n>>>>>>> conflict ends\nunsaved tail",
snapshot("left one\n", "old one\n", "right one\n")
);
for (index, first, second) in [
(0, "left one", "left two"),
(1, "old one", "old two"),
(2, "right one", "right two"),
] {
assert_eq!(
project(&raw, &sources[index]).unwrap(),
format!(
"unsaved heading\nalready manually resolved\n{first}\nauto-merged middle\n{second}\nunsaved tail"
)
);
}
}
#[test]
fn alternatives_are_frozen_from_the_same_working_text_as_the_seed() {
let sources = test_sources(2);
let raw = format!(
"unsaved heading\n{}auto-merged middle\n{}unsaved tail",
snapshot("left-one\n", "old-one\n", "right-one\n"),
snapshot("left-two\n", "old-two\n", "right-two\n")
);
let projection = Projection::new(&raw, &sources, 0).unwrap();
let mut draft = projection.seed().to_owned();
draft = draft.replace("unsaved heading", "later draft edit");
draft.push_str("\nnew draft ending\n");
assert_eq!(
projection.alternative(2).unwrap(),
"unsaved heading\nright-one\nauto-merged middle\nright-two\nunsaved tail"
);
assert_eq!(
projection.alternative(1).unwrap(),
"unsaved heading\nold-one\nauto-merged middle\nold-two\nunsaved tail"
);
assert_eq!(
projection.seed(),
"unsaved heading\nleft-one\nauto-merged middle\nleft-two\nunsaved tail"
);
assert_eq!(
draft,
"later draft edit\nleft-one\nauto-merged middle\nleft-two\nunsaved tail\nnew draft ending\n"
);
}
#[test]
fn all_multiway_sides_and_bases_project_the_same_current_context() {
let sources = test_sources(3);
let raw = "unsaved α\n<<<<<<<<<<< conflict\n+++++++++++ side 1\none\n----------- base 1\nold one\n+++++++++++ side 2\ntwo\n----------- base 2\nold two\n+++++++++++ side 3\nthree\n>>>>>>>>>>> conflict ends\nunsaved ω";
let projection = Projection::new(raw, &sources, 0).unwrap();
for (index, choice) in ["one", "old one", "two", "old two", "three"]
.into_iter()
.enumerate()
{
let expected = format!("unsaved α\n{choice}\nunsaved ω");
assert_eq!(project(raw, &sources[index]).unwrap(), expected);
assert_eq!(projection.alternative(index).unwrap(), expected);
}
assert_eq!(
Projection::new(raw, &sources, 4).unwrap().seed(),
"unsaved α\nthree\nunsaved ω"
);
}
#[test]
fn projections_preserve_mixed_eof_and_crlf_terms() {
let sources = test_sources(2);
for eol in ["\n", "\r\n"] {
let choices = [
format!("left{eol}"),
"old".to_owned(),
format!("right{eol}{eol}"),
];
// jj appends one separator EOL to *each* term when any lacks an EOL,
// and omits the EOL on the final marker. Existing term EOLs remain.
let raw = format!(
"heading{eol}<<<<<<< conflict{eol}+++++++ side 1{eol}{}{eol}------- base{eol}{}{eol}+++++++ side 2{eol}{}{eol}>>>>>>> conflict ends",
choices[0], choices[1], choices[2]
);
let projection = Projection::new(&raw, &sources, 0).unwrap();
for (index, choice) in choices.iter().enumerate() {
assert_eq!(
project(&raw, &sources[index]).unwrap(),
format!("heading{eol}{choice}")
);
assert_eq!(
projection.alternative(index).unwrap(),
format!("heading{eol}{choice}")
);
}
}
}
#[test]
fn empty_choices_markerfree_text_and_invalid_choices_are_handled() {
let mut sources = test_sources(2);
sources[1].kind = FileKind::Deleted;
let raw = format!("before\n{}after\n", snapshot("", "", "new\n"));
let projection = Projection::new(&raw, &sources, 0).unwrap();
assert_eq!(projection.seed(), "before\nafter\n");
assert_eq!(projection.alternative(1).unwrap(), "before\nafter\n");
assert_eq!(projection.alternative(2).unwrap(), "before\nnew\nafter\n");
let resolved = Projection::new("manually resolved, no final EOL", &sources, 0).unwrap();
assert_eq!(
resolved.alternative(2).unwrap(),
"manually resolved, no final EOL"
);
assert!(resolved.alternative(sources.len()).is_err());
assert!(Projection::new(&raw, &sources, sources.len()).is_err());
assert!(Projection::new(&raw, &test_sources(3), 0).is_err());
sources[0].kind = FileKind::Binary;
assert!(project(&raw, &sources[0]).is_err());
}
+927
View File
@@ -0,0 +1,927 @@
use super::*;
struct Directory(PathBuf);
impl Drop for Directory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn directory() -> Directory {
let directory = Directory(std::env::temp_dir().join(format!(
"vamp-review-test-{}-{}",
std::process::id(),
NEXT_TEMP.fetch_add(1, Ordering::Relaxed),
)));
fs::create_dir(&directory.0).unwrap();
directory
}
#[test]
fn database_restarts_preserve_identity_and_exact_stale_anchors() {
let directory = directory();
let root = directory.0.canonicalize().unwrap();
let storage = root.join("storage");
let mut review = Review::new(root.clone());
review.summary = "Unicode review\nMore context".into();
review.verdict = Some(Verdict::ChangesRequested);
review.comments.push(Comment {
id: 7,
anchor: Anchor {
revision: "working:old-snapshot".into(),
path: "日本語.rs".into(),
side: Side::Before,
start: crate::plugin::TextPosition { row: 0, column: 1 },
end: crate::plugin::TextPosition { row: 1, column: 1 },
content: "a👩‍🔬e\u{301}\r\n界x\n".into(),
},
body: "Please preserve 👩‍🔬".into(),
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();
store.save(&mut review).unwrap();
assert!(review.storage_id.is_some());
assert_eq!(review.storage_revision, 1);
}
let mut store = Store::open(&storage).unwrap();
let id = review.storage_id.unwrap();
let listed = store.list(&root).unwrap();
assert_eq!(listed[0].storage_id, Some(id));
assert_eq!(listed[0].summary, review.summary);
assert_eq!(listed[0].comments, review.comments);
assert_eq!(listed[0].reviewed_files, review.reviewed_files);
let mut loaded = store.load(&root, id).unwrap();
assert_eq!(loaded.storage_id, Some(id));
assert_eq!(loaded.storage_revision, 1);
assert_eq!(loaded.comments, review.comments);
assert_eq!(loaded.reviewed_files, review.reviewed_files);
assert_eq!(loaded.verdict, review.verdict);
assert!(
loaded.comments[0]
.anchor
.mapped_range(
"working:new-snapshot",
Path::new("日本語.rs"),
Side::Before,
&["changed".into()],
true,
)
.is_none()
);
let path = root.join("export.json");
loaded.save(&path).unwrap();
let exported = fs::read(&path).unwrap();
let json: serde_json::Value = serde_json::from_slice(&exported).unwrap();
assert_eq!(json["comments"][0]["resolved"], true);
assert_eq!(json["version"], 4);
assert_eq!(json["reviewed_files"][0]["after"], "live 👩‍🔬\r\n");
assert!(json["reviewed_files"][1]["after"].is_null());
assert_eq!(json["reviewed_files"][2]["after"], "");
assert!(json.get("storage_id").is_none());
assert!(json.get("storage_revision").is_none());
loaded.summary = "Database only".into();
store.save(&mut loaded).unwrap();
assert_eq!(loaded.storage_id, Some(id));
assert_eq!(loaded.storage_revision, 2);
assert_eq!(fs::read(&path).unwrap(), exported);
let mut imported = Review::load(&path, &root).unwrap();
assert_eq!(imported.storage_id, None);
assert_eq!(imported.storage_revision, 0);
assert_eq!(imported.reviewed_files, review.reviewed_files);
store.save(&mut imported).unwrap();
assert_ne!(imported.storage_id, Some(id));
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 database_repository_identity_is_canonical_and_independent() {
let first = directory();
let second = directory();
let first_root = first.0.canonicalize().unwrap();
let second_root = second.0.canonicalize().unwrap();
let mut store = Store::open(&first_root.join("storage")).unwrap();
let mut a = Review::new(first_root.clone());
let mut b = Review::new(second_root.clone());
store.save(&mut a).unwrap();
store.save(&mut b).unwrap();
assert_ne!(a.storage_id, b.storage_id);
assert_eq!(store.list(&first_root).unwrap().len(), 1);
assert_eq!(store.list(&second_root).unwrap().len(), 1);
assert!(store.load(&second_root, a.storage_id.unwrap()).is_err());
let id = a.storage_id;
let revision = a.storage_revision;
a.root = second_root;
assert!(store.save(&mut a).is_err());
assert_eq!((a.storage_id, a.storage_revision), (id, revision));
#[cfg(unix)]
{
let alias = first_root.join("alias");
std::os::unix::fs::symlink(&first_root, &alias).unwrap();
assert_eq!(
store.list(&alias).unwrap()[0].storage_id,
store.list(&first_root).unwrap()[0].storage_id
);
}
}
#[test]
fn database_conflicts_and_locked_writes_keep_drafts_and_identity() {
let directory = directory();
let root = directory.0.canonicalize().unwrap();
let storage = root.join("storage");
let mut first = Store::open(&storage).unwrap();
let mut second = Store::open(&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,
});
first.save(&mut review).unwrap();
let id = review.storage_id.unwrap();
let mut draft = second.load(&root, id).unwrap();
review.summary = "First writer".into();
review.reviewed_files[0].after = Some("First writer buffer\n".into());
first.save(&mut review).unwrap();
draft.summary = "Second writer draft".into();
draft.reviewed_files.clear();
assert!(
second
.save(&mut draft)
.unwrap_err()
.contains("another session")
);
assert_eq!(draft.summary, "Second writer draft");
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().reviewed_files,
review.reviewed_files,
);
assert!(draft.reviewed_files.is_empty());
first.connection.execute_batch("BEGIN IMMEDIATE").unwrap();
let mut fresh = Review::new(root.clone());
assert!(second.save(&mut fresh).unwrap_err().contains("locked"));
assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0));
first.connection.execute_batch("ROLLBACK").unwrap();
second.save(&mut fresh).unwrap();
assert!(fresh.storage_id.is_some());
}
#[test]
fn incompatible_database_schemas_are_preserved() {
let previous = format!(
"{REVIEWS_SCHEMA}; {COMMENTS_SCHEMA};
INSERT INTO reviews VALUES (1, '/old/repo', 1, 'Preserve me', 'looks_good');
PRAGMA user_version = 2"
);
for schema in [
previous.as_str(),
"CREATE TABLE other (value TEXT); PRAGMA user_version = 42",
"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 path = directory.0.join("reviews.sqlite3");
let connection = Connection::open(&path).unwrap();
connection.execute_batch(schema).unwrap();
drop(connection);
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_eq!(fs::read(&path).unwrap(), original);
}
}
#[test]
fn malformed_database_rows_are_rejected_before_loading_snapshots() {
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.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();
let id = review.storage_id.unwrap();
for update in [
"resolved = 2",
"side = 'invalid'",
"id = '0'",
"id = '18446744073709551616'",
"start_row = -1",
"end_column = -1",
] {
assert!(
store
.connection
.execute(
&format!("UPDATE comments SET {update} WHERE review_id = ?1"),
[id],
)
.is_err()
);
assert_eq!(store.load(&root, id).unwrap().comments, review.comments);
}
store
.connection
.execute_batch("PRAGMA ignore_check_constraints = ON")
.unwrap();
for update in [
"path = '../outside'",
"end_column = 2",
"resolved = 2",
"side = 'invalid'",
"anchor_revision = ''",
"id = '0'",
"position = 1",
] {
store
.connection
.execute(
&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],
)
.unwrap();
assert!(store.load(&root, id).unwrap_err().contains("64 MiB"));
assert!(store.list(&root).unwrap_err().contains("64 MiB"));
}
#[test]
fn failed_database_and_export_writes_preserve_metadata_and_documents() {
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 identity = (review.storage_id, review.storage_revision);
store
.connection
.execute_batch("PRAGMA query_only = ON")
.unwrap();
review.summary = "Unsaved draft".into();
assert!(store.save(&mut review).is_err());
assert_eq!((review.storage_id, review.storage_revision), identity);
assert_eq!(review.summary, "Unsaved draft");
assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, "");
let mut fresh = Review::new(root.clone());
assert!(store.save(&mut fresh).is_err());
assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0));
store
.connection
.execute_batch("PRAGMA query_only = OFF")
.unwrap();
review.summary = "x".repeat(LIMIT);
assert!(store.save(&mut review).unwrap_err().contains("64 MiB"));
assert!(
review
.save(&root.join("oversized.json"))
.unwrap_err()
.contains("64 MiB")
);
assert!(!root.join("oversized.json").exists());
assert_eq!((review.storage_id, review.storage_revision), identity);
assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, "");
let blocked = root.join("not-a-directory");
fs::write(&blocked, b"preserve").unwrap();
assert!(Store::open(&blocked).is_err());
assert_eq!(fs::read(&blocked).unwrap(), b"preserve");
}
#[cfg(unix)]
#[test]
fn database_files_are_private_and_target_symlinks_are_refused() {
use std::os::unix::fs::PermissionsExt;
let directory = directory();
let storage = directory.0.join("storage");
drop(Store::open(&storage).unwrap());
let path = storage.join("reviews.sqlite3");
assert_eq!(
fs::metadata(&storage).unwrap().permissions().mode() & 0o777,
0o700
);
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
let original = fs::read(&path).unwrap();
let links = directory.0.join("links");
fs::create_dir(&links).unwrap();
std::os::unix::fs::symlink(&path, links.join("reviews.sqlite3")).unwrap();
assert!(Store::open(&links).err().unwrap().contains("symbolic link"));
assert_eq!(fs::read(&path).unwrap(), original);
}
#[test]
fn default_directory_uses_absolute_xdg_then_absolute_home() {
let resolve = |xdg: Option<&str>, home: Option<&str>| {
directory_from(xdg.map(OsStr::new), home.map(OsStr::new))
};
assert_eq!(
resolve(Some("/data"), Some("/home/me")).unwrap(),
Path::new("/data/vamp/reviews")
);
for xdg in [None, Some("relative"), Some("")] {
assert_eq!(
resolve(xdg, Some("/home/me")).unwrap(),
Path::new("/home/me/.local/share/vamp/reviews")
);
}
assert_eq!(
resolve(Some("/data"), None).unwrap(),
Path::new("/data/vamp/reviews")
);
assert!(resolve(None, None).is_err());
assert!(resolve(None, Some("relative")).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]
fn persistence_validates_anchors_and_preserves_files_on_failure() {
let directory = directory();
let root = directory.0.canonicalize().unwrap();
let path = root.join("review.json");
let mut review = Review::new(root.clone());
review.summary = "Local review".into();
review.verdict = Some(Verdict::ChangesRequested);
review.comments.push(Comment {
id: 1,
anchor: Anchor {
revision: "working:immutable".into(),
path: "source.rs".into(),
side: Side::After,
start: crate::plugin::TextPosition { row: 0, column: 0 },
end: crate::plugin::TextPosition { row: 2, column: 0 },
content: "first\nlast\n".into(),
},
body: "Please adjust this".into(),
resolved: false,
});
review.save(&path).unwrap();
let mut loaded = Review::load(&path, &root).unwrap();
assert_eq!(loaded.comments, review.comments);
assert_eq!(loaded.verdict, review.verdict);
let anchor = &loaded.comments[0].anchor;
assert!(
anchor
.mapped_range(
"working:other",
Path::new("source.rs"),
Side::After,
&["first".into(), "last".into()],
true
)
.is_some()
);
assert!(
anchor
.mapped_range(
"working:immutable",
Path::new("source.rs"),
Side::After,
&["first".into(), "last".into()],
true
)
.is_some()
);
assert!(
anchor
.mapped_range(
"working:immutable",
Path::new("source.rs"),
Side::After,
&["first".into(), "last".into()],
false
)
.is_none()
);
loaded.summary = "Updated".into();
loaded.save(&path).unwrap();
assert_eq!(Review::load(&path, &root).unwrap().summary, "Updated");
assert!(Review::new(root.clone()).save(&path).is_err());
let valid = fs::read(&path).unwrap();
for (field, value) in [
("version", serde_json::json!(2)),
("root", serde_json::json!(root.join("other"))),
(
"comments",
serde_json::json!([loaded.comments[0], loaded.comments[0]]),
),
] {
let mut invalid = serde_json::to_value(&loaded).unwrap();
invalid[field] = value;
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
assert!(Review::load(&path, &root).is_err());
}
for (field, value) in [
("path", serde_json::json!("../outside")),
("path", serde_json::json!("/absolute")),
("path", serde_json::json!(".git/config")),
("start", serde_json::json!({"row": 2, "column": 1})),
("start", serde_json::json!({"row": 3, "column": 0})),
("end", serde_json::json!({"row": 3, "column": 0})),
("revision", serde_json::json!("")),
] {
let mut invalid = serde_json::to_value(&loaded).unwrap();
invalid["comments"][0]["anchor"][field] = value;
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
assert!(Review::load(&path, &root).is_err());
}
fs::create_dir(root.join("other")).unwrap();
fs::write(&path, &valid).unwrap();
assert!(Review::load(&path, &root.join("other")).is_err());
fs::write(&path, b"external edit").unwrap();
assert!(loaded.save(&path).is_err());
assert_eq!(fs::read(&path).unwrap(), b"external edit");
assert_eq!(loaded.summary, "Updated");
let invalid = root.join("invalid.json");
loaded.comments[0].anchor.start.row = usize::MAX;
assert!(loaded.save(&invalid).is_err());
assert!(!invalid.exists());
let large = root.join("large.json");
fs::File::create(&large)
.unwrap()
.set_len(LIMIT as u64 + 1)
.unwrap();
assert!(Review::load(&large, &root).is_err());
#[cfg(unix)]
{
let link = root.join("link.json");
std::os::unix::fs::symlink(&path, &link).unwrap();
assert!(Review::load(&link, &root).is_err());
assert!(review.save(&link).is_err());
assert_eq!(fs::read(&path).unwrap(), b"external edit");
}
}
#[test]
fn comment_rows_preserve_order_full_ids_and_atomic_replacement() {
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.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();
let id = review.storage_id.unwrap();
assert_eq!(store.load(&root, id).unwrap().comments, review.comments);
assert!(
store
.connection
.execute(
"UPDATE comments SET review_id = ?1 WHERE review_id = ?2",
params![id + 1, id],
)
.is_err()
);
assert!(
store
.connection
.execute("UPDATE comments SET id = '7' WHERE review_id = ?1", [id],)
.is_err()
);
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();
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]
fn exact_grapheme_ranges_roundtrip_and_reject_invalid_coordinates() {
use crate::plugin::TextPosition;
let directory = directory();
let root = directory.0.canonicalize().unwrap();
let path = root.join("review.json");
let mut review = Review::new(root.clone());
review.comments.push(Comment {
id: 1,
anchor: Anchor {
revision: "working:immutable".into(),
path: "source.rs".into(),
side: Side::After,
start: TextPosition { row: 0, column: 1 },
end: TextPosition { row: 1, column: 1 },
content: "a👩‍🔬e\u{301}\n界x\n".into(),
},
body: "Exact text".into(),
resolved: false,
});
review.save(&path).unwrap();
let mut loaded = Review::load(&path, &root).unwrap();
assert_eq!(loaded.comments, review.comments);
assert_eq!(loaded.comments[0].anchor.rows(), 0..=1);
let valid = serde_json::to_value(&review).unwrap();
let mut missing_resolution = valid.clone();
missing_resolution["comments"][0]
.as_object_mut()
.unwrap()
.remove("resolved");
for version in [2, 3, 4] {
missing_resolution["version"] = version.into();
let bytes = serde_json::to_vec(&missing_resolution).unwrap();
fs::write(&path, &bytes).unwrap();
let error = Review::load(&path, &root).unwrap_err();
if version < 4 {
assert!(error.contains("Unsupported review version"));
} else {
assert!(error.contains("missing field"));
}
assert_eq!(fs::read(&path).unwrap(), bytes);
}
for (field, value) in [
("start", serde_json::json!({"row": 0, "column": 4})),
("end", serde_json::json!({"row": 1, "column": 3})),
("end", serde_json::json!({"row": 2, "column": 1})),
("end", serde_json::json!({"row": 3, "column": 0})),
("end", serde_json::json!({"row": 0, "column": 0})),
("start", serde_json::json!({"row": -1, "column": 0})),
(
"start",
serde_json::json!({"row": 0, "column": 0, "extra": 0}),
),
("start_line", serde_json::json!(1)),
] {
let mut invalid = valid.clone();
invalid["comments"][0]["anchor"][field] = value;
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
assert!(Review::load(&path, &root).is_err(), "{field}");
}
let mut wrong_version = valid;
wrong_version["version"] = 1.into();
fs::write(&path, serde_json::to_vec(&wrong_version).unwrap()).unwrap();
assert!(Review::load(&path, &root).is_err());
loaded.comments[0].anchor.end = TextPosition { row: 0, column: 0 };
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"));
}
+107
View File
@@ -0,0 +1,107 @@
use super::*;
use vamp::{
plugin::TextPosition,
review::{Anchor, Comment, Side},
};
#[test]
fn output_filters_resolution_and_extracts_exact_graphemes() {
let mut review = Review::new(PathBuf::new());
review.storage_id = Some(7);
review.storage_revision = 2;
review.comments = [false, true]
.into_iter()
.enumerate()
.map(|(id, resolved)| Comment {
id: id as u64 + 1,
body: "fix".into(),
resolved,
anchor: Anchor {
revision: "commit".into(),
path: "a.rs".into(),
side: Side::After,
start: TextPosition { row: 0, column: 1 },
end: TextPosition { row: 1, column: 1 },
content: "xe\u{301}👩‍💻\n界z\n".into(),
},
})
.collect();
let output = envelope(&review, false, true);
assert_eq!(output["comments"].as_array().unwrap().len(), 1);
assert_eq!(output["comments"][0]["excerpt"], "e\u{301}👩‍💻\n界");
assert!(output["comments"][0]["anchor"].get("content").is_none());
assert_eq!(output["counts"]["resolved"], 1);
assert_eq!(
envelope(&review, true, true)["comments"]
.as_array()
.unwrap()
.len(),
2
);
}
#[test]
fn absent_storage_is_not_created_and_incompatible_storage_is_preserved() {
let directory = std::env::temp_dir().join(format!("vamp-review-cli-{}", std::process::id()));
std::fs::create_dir(&directory).unwrap();
let absent = directory.join("absent");
assert!(Store::open_read_only(&absent).unwrap().is_none());
assert!(!absent.exists());
let path = directory.join("reviews.sqlite3");
std::fs::write(&path, b"incompatible").unwrap();
assert!(Store::open_read_only(&directory).is_err());
assert_eq!(std::fs::read(&path).unwrap(), b"incompatible");
std::fs::remove_file(&path).unwrap();
let connection = rusqlite::Connection::open(&path).unwrap();
connection
.execute_batch("CREATE TABLE incompatible (value TEXT); PRAGMA user_version = 42")
.unwrap();
drop(connection);
let before = std::fs::read(&path).unwrap();
assert!(
Store::open_read_only(&directory)
.err()
.unwrap()
.contains("schema")
);
assert_eq!(std::fs::read(&path).unwrap(), before);
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn existing_storage_is_read_only_and_unchanged() {
let directory =
std::env::temp_dir().join(format!("vamp-review-cli-existing-{}", std::process::id()));
let mut store = Store::open(&directory).unwrap();
let mut review = Review::new(directory.clone());
review.summary = "stored".into();
store.save(&mut review).unwrap();
drop(store);
let path = directory.join("reviews.sqlite3");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
}
let before = std::fs::read(&path).unwrap();
let mut store = Store::open_read_only(&directory).unwrap().unwrap();
let mut loaded = store.load(&directory, review.storage_id.unwrap()).unwrap();
assert_eq!(loaded.summary, "stored");
loaded.summary = "changed".into();
assert!(store.save(&mut loaded).is_err());
drop(store);
assert_eq!(std::fs::read(&path).unwrap(), before);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o640
);
let links = directory.join("links");
std::fs::create_dir(&links).unwrap();
std::os::unix::fs::symlink(&path, links.join("reviews.sqlite3")).unwrap();
assert!(Store::open_read_only(&links).is_err());
}
std::fs::remove_dir_all(directory).unwrap();
}
+2438
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+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(()));
}
+119
View File
@@ -0,0 +1,119 @@
use super::*;
#[test]
fn grammar_expands_nested_mirrors_choices_variables_and_normalized_offsets() {
let (text, snippet) = expand(
"call($1, ${1:λ${2:x}}, ${3|a\\,b,c|}, ${UNKNOWN:${TM_LINE_NUMBER}})\r\n$0\\$",
|name| (name == "TM_LINE_NUMBER").then(|| "4".to_owned()),
)
.unwrap();
assert_eq!(text, "call(λx, λx, a,b, 4)\n$");
assert_eq!(
snippet.tabstops,
vec![
SnippetTabstop {
index: 1,
ranges: vec![5..8, 10..13],
ancestors: vec![vec![], vec![]]
},
SnippetTabstop {
index: 2,
ranges: vec![7..8, 12..13],
ancestors: vec![vec![1], vec![1]]
},
SnippetTabstop {
index: 3,
ranges: vec![15..18],
ancestors: vec![vec![]]
},
SnippetTabstop {
index: 0,
ranges: vec![23..23],
ancestors: vec![vec![]]
},
]
);
let (text, snippet) = expand("call($1, ${2})", |_| None).unwrap();
assert_eq!(text, "call(, )");
assert_eq!(
snippet.tabstops,
vec![
SnippetTabstop {
index: 1,
ranges: vec![5..5],
ancestors: vec![vec![]]
},
SnippetTabstop {
index: 2,
ranges: vec![7..7],
ancestors: vec![vec![]]
},
SnippetTabstop {
index: 0,
ranges: vec![8..8],
ancestors: vec![vec![]]
},
]
);
assert_eq!(
expand("${KNOWN:${1:hidden}}$1", |name| {
(name == "KNOWN").then(|| "shown".to_owned())
})
.unwrap()
.0,
"shown"
);
let (text, snippet) = expand("call(${CTX})$1", |name| {
(name == "CTX").then(|| "a\r\nb\rc".to_owned())
})
.unwrap();
assert_eq!(text, "call(a\nb\nc)");
assert_eq!(snippet.tabstops[0].ranges, [11..11]);
assert_eq!(
expand("${1|a\\|b,c|}\\}\\\\", |_| None).unwrap().0,
"a|b}\\"
);
for input in ["${1", "${1:$1}", "${1:a}${1:b}$1", "${1:e}\u{301}"] {
if input == "${1:a}${1:b}$1" {
assert_eq!(expand(input, |_| None).unwrap().0, "aaa");
} else {
assert_eq!(expand(input, |_| None), Err(Error::Invalid));
}
}
let (text, snippet) = expand("${1}$1", |_| None).unwrap();
assert!(text.is_empty());
assert_eq!(
snippet.tabstops[0],
SnippetTabstop {
index: 1,
ranges: vec![0..0, 0..0],
ancestors: vec![vec![], vec![]],
}
);
for input in [
"$UNKNOWN",
"${TM_FILENAME/(.*)/$1/}",
"${1/a/b/}",
"${1:a$0b}",
"$0$0",
"${0:arg}",
"${0:$1}",
] {
assert_eq!(expand(input, |_| None), Err(Error::Unsupported));
}
assert_eq!(
expand(&"$1".repeat(MAX_STOPS + 1), |_| None),
Err(Error::Limit)
);
assert_eq!(
expand(
&format!(
"{}x{}",
"${1:".repeat(MAX_DEPTH + 1),
"}".repeat(MAX_DEPTH + 1)
),
|_| None
),
Err(Error::Limit)
);
}
+1346
View File
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
use super::*;
#[test]
fn selected_files_and_auxiliary_grammars_are_registered() {
for (filename, language) in [
("main.rs", "Rust"),
("Gemfile", "Ruby"),
("main.py", "Python"),
("package.json", "JSON"),
(".bashrc", "Bash"),
("GNUmakefile", "Makefile"),
("README.md", "Markdown"),
("main.jsx", "JavaScript"),
("main.ts", "TypeScript"),
("index.html", "HTML"),
("style.css", "CSS"),
("config.yml", "YAML"),
("Cargo.toml", "TOML"),
("Cargo.lock", "TOML"),
("main.c", "C"),
("main.cpp", "C++"),
("main.C", "C++"),
("main.go", "Go"),
("main.zig", "Zig"),
("main.typ", "Typst"),
("main.TYP", "Typst"),
] {
assert_eq!(
detect(Some(Path::new(filename)), None).map(|spec| spec.name),
Some(language)
);
}
let ts = detect(Some(Path::new("main.ts")), None).unwrap();
let tsx = detect(Some(Path::new("main.tsx")), None).unwrap();
assert_eq!(ts.name, tsx.name);
assert!(!std::ptr::eq(ts, tsx));
assert_ne!(ts.language, tsx.language);
assert!(std::ptr::eq(tsx, by_name("TSX").unwrap()));
assert!(by_name("markdown_inline").is_some());
for filename in [
"notes.txt",
"main.hs",
"main.scala",
"main.lua",
"Dockerfile",
] {
assert!(detect(Some(Path::new(filename)), None).is_none());
}
}
#[test]
fn shebangs_support_bom_env_options_and_filename_precedence() {
for (line, language) in [
("#!/usr/bin/python3", "Python"),
("\u{feff}#!/usr/bin/env -S python3.12 -u", "Python"),
("#!/usr/bin/env -u PYTHONHOME FOO=bar python3", "Python"),
("#!/usr/bin/ruby3.3", "Ruby"),
("#!/bin/bash", "Bash"),
("#! /usr/bin/env node", "JavaScript"),
] {
assert_eq!(
detect(None, Some(line)).map(|spec| spec.name),
Some(language)
);
}
assert_eq!(
detect(Some(Path::new("main.rs")), Some("#!/bin/bash"))
.unwrap()
.name,
"Rust"
);
assert!(detect(None, Some("#!/usr/bin/python-not-real")).is_none());
assert!(detect(None, Some("#!/usr/bin/perl")).is_none());
assert!(detect(None, Some("plain text")).is_none());
}
+273
View File
@@ -0,0 +1,273 @@
use super::*;
use crossterm::event::{KeyEvent, MouseEvent, MouseEventKind};
fn key(code: KeyCode, modifiers: KeyModifiers) -> Event {
Event::Key(KeyEvent::new(code, modifiers))
}
fn bytes(collector: &mut TerminalColors, text: &str, now: Instant) {
for ch in text.chars() {
let modifiers = if ch.is_ascii_uppercase() {
KeyModifiers::SHIFT
} else {
KeyModifiers::NONE
};
collector.push(key(KeyCode::Char(ch), modifiers), now);
}
}
fn report(collector: &mut TerminalColors, body: &str, now: Instant) {
collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), now);
bytes(collector, body, now);
collector.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL), now);
}
fn drain(collector: &mut TerminalColors) -> Vec<Event> {
std::iter::from_fn(|| collector.pop()).collect()
}
#[test]
fn fragmented_triple_accepts_bel_and_both_st_encodings() {
let now = Instant::now();
let mut collector = TerminalColors::default();
collector.query(&mut Vec::new(), now).unwrap();
collector.push(key(KeyCode::Esc, KeyModifiers::NONE), now);
collector.push(
key(KeyCode::Char(']'), KeyModifiers::NONE),
now + Duration::from_millis(1),
);
bytes(
&mut collector,
"11;rgb:0/12/345",
now + Duration::from_millis(2),
);
collector.push(
key(KeyCode::Esc, KeyModifiers::NONE),
now + Duration::from_millis(3),
);
collector.push(
key(KeyCode::Char('\\'), KeyModifiers::NONE),
now + Duration::from_millis(4),
);
assert_eq!(collector.colors, None);
report(
&mut collector,
"4;2;rgb:f/ffff/ABCD",
now + Duration::from_millis(5),
);
collector.push(
key(KeyCode::Char(']'), KeyModifiers::ALT),
now + Duration::from_millis(6),
);
bytes(
&mut collector,
"4;1;#FF1234",
now + Duration::from_millis(7),
);
collector.push(
key(KeyCode::Char('\\'), KeyModifiers::ALT),
now + Duration::from_millis(8),
);
assert_eq!(
collector.colors,
Some(ThemeColors {
background: [0, 18, 52],
red: [255, 18, 52],
green: [255, 255, 171],
})
);
assert!(drain(&mut collector).is_empty());
}
#[test]
fn focus_refresh_atomically_replaces_light_and_dark_triples() {
let now = Instant::now();
let mut collector = TerminalColors::default();
let palettes = [
("11;#FDF6E3", [253, 246, 227]),
("11;rgb:0000/0000/0000", [0, 0, 0]),
];
for (cycle, (background, expected)) in palettes.into_iter().enumerate() {
let time = now + Duration::from_secs(cycle as u64);
let focus = Event::FocusGained;
collector.push(focus.clone(), time);
assert_eq!(collector.pop(), Some(focus));
let previous = collector.colors;
collector.query(&mut Vec::new(), time).unwrap();
report(&mut collector, "4;1;rgb:ff/00/00", time);
report(&mut collector, background, time);
assert_eq!(collector.colors, previous);
report(&mut collector, "4;2;rgb:00/ff/00", time);
assert_eq!(
collector.colors,
Some(ThemeColors {
background: expected,
red: [255, 0, 0],
green: [0, 255, 0],
})
);
assert!(drain(&mut collector).is_empty());
}
}
#[test]
fn periodic_refresh_detects_silent_change_without_overlapping_queries() {
let now = Instant::now();
let mut collector = TerminalColors::default();
let mut output = Vec::new();
assert!(collector.refresh_due(now));
collector.query(&mut output, now).unwrap();
report(&mut collector, "11;#101010", now);
report(&mut collector, "4;1;#FF0000", now);
report(&mut collector, "4;2;#00FF00", now);
let previous = collector.colors;
assert!(previous.is_some());
// The terminal changes its palette without emitting a notification.
let refresh = now + Duration::from_secs(1);
assert!(!collector.refresh_due(refresh - Duration::from_nanos(1)));
assert!(collector.refresh_due(refresh));
collector.query(&mut output, refresh).unwrap();
report(&mut collector, "11;#EEEEEE", refresh);
report(&mut collector, "4;1;#AA1122", refresh);
assert_eq!(collector.colors, previous);
let typed = key(KeyCode::Char('i'), KeyModifiers::NONE);
collector.push(typed.clone(), refresh);
assert_eq!(drain(&mut collector), vec![typed]);
// Even an overdue probe must wait until the incomplete query expires.
let retry = refresh + Duration::from_secs(1);
assert!(!collector.refresh_due(retry));
collector.expire(retry);
assert_eq!(collector.colors, None);
assert!(collector.refresh_due(retry));
collector.query(&mut output, retry).unwrap();
report(&mut collector, "11;#EEEEEE", retry);
report(&mut collector, "4;1;#AA1122", retry);
assert_eq!(collector.colors, None);
report(&mut collector, "4;2;#22BB33", retry);
assert_eq!(
collector.colors,
Some(ThemeColors {
background: [238, 238, 238],
red: [170, 17, 34],
green: [34, 187, 51],
})
);
assert!(drain(&mut collector).is_empty());
assert!(!collector.refresh_due(retry + Duration::from_millis(999)));
assert!(collector.refresh_due(retry + Duration::from_secs(1)));
}
#[test]
fn failed_refresh_clears_stale_colors_and_consumes_late_partial_reply() {
let now = Instant::now();
let mut collector = TerminalColors::default();
collector.query(&mut Vec::new(), now).unwrap();
report(&mut collector, "11;#101010", now);
report(&mut collector, "4;1;#FF0000", now);
report(&mut collector, "4;2;#00FF00", now);
assert!(collector.colors.is_some());
let refresh = now + Duration::from_secs(1);
collector.query(&mut Vec::new(), refresh).unwrap();
report(&mut collector, "11;#EEEEEE", refresh);
collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), refresh);
bytes(&mut collector, "4;1;rgb:ffff/", refresh);
collector.expire(refresh + QUERY_TIMEOUT);
assert_eq!(collector.colors, None);
let late = refresh + Duration::from_secs(1);
bytes(&mut collector, "0000/0000", late);
collector.push(key(KeyCode::Esc, KeyModifiers::NONE), late);
collector.expire(late + Duration::from_secs(1));
collector.push(
key(KeyCode::Char('\\'), KeyModifiers::NONE),
late + Duration::from_secs(1),
);
report(&mut collector, "4;2;#00FF00", late + Duration::from_secs(1));
assert_eq!(collector.colors, None);
assert!(drain(&mut collector).is_empty());
let typed = key(KeyCode::Char('i'), KeyModifiers::NONE);
collector.push(typed.clone(), late + Duration::from_secs(2));
assert_eq!(collector.pop(), Some(typed));
}
#[test]
fn unrelated_events_and_rejected_candidates_replay_exactly() {
let now = Instant::now();
let mut collector = TerminalColors::default();
collector.query(&mut Vec::new(), now).unwrap();
let events = vec![
key(KeyCode::Char(']'), KeyModifiers::ALT),
key(KeyCode::Char('4'), KeyModifiers::NONE),
key(KeyCode::Char(';'), KeyModifiers::NONE),
key(KeyCode::Char('3'), KeyModifiers::NONE),
key(KeyCode::Char('g'), KeyModifiers::CONTROL),
key(KeyCode::Char(']'), KeyModifiers::ALT | KeyModifiers::SHIFT),
Event::Key(KeyEvent::new_with_kind(
KeyCode::Char(']'),
KeyModifiers::ALT,
KeyEventKind::Release,
)),
Event::Key(KeyEvent::new_with_kind(
KeyCode::Char('A'),
KeyModifiers::SHIFT,
KeyEventKind::Repeat,
)),
Event::Key(KeyEvent {
code: KeyCode::Char('a'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
state: KeyEventState::CAPS_LOCK,
}),
Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
column: 2,
row: 3,
modifiers: KeyModifiers::CONTROL,
}),
Event::FocusLost,
Event::Resize(80, 24),
key(KeyCode::Char(']'), KeyModifiers::ALT),
Event::Paste("\x1b]11;rgb:ff/ff/ff\x07".into()),
Event::FocusGained,
];
for event in &events {
collector.push(event.clone(), now);
}
assert_eq!(drain(&mut collector), events);
assert_eq!(collector.colors, None);
}
#[test]
fn malformed_or_oversized_reports_and_lone_escape_do_not_eat_input() {
let now = Instant::now();
let mut collector = TerminalColors::default();
for body in [
"11;rgb:12345/0/0",
"11;rgb:1//3",
"11;#12345",
"11;rgb:1/2/3/4",
] {
let mut expected = vec![key(KeyCode::Char(']'), KeyModifiers::ALT)];
expected.extend(
body.chars()
.map(|ch| key(KeyCode::Char(ch), KeyModifiers::NONE)),
);
expected.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL));
for event in &expected {
collector.push(event.clone(), now);
}
assert_eq!(drain(&mut collector), expected);
}
let escape = key(KeyCode::Esc, KeyModifiers::NONE);
collector.push(escape.clone(), now);
assert_eq!(collector.pop(), None);
collector.expire(now + ESCAPE_TIMEOUT);
assert_eq!(collector.pop(), Some(escape));
assert_eq!(collector.pop(), None);
let start = key(KeyCode::Char(']'), KeyModifiers::ALT);
collector.push(start.clone(), now + Duration::from_secs(1));
collector.expire(now + Duration::from_secs(1) + QUERY_TIMEOUT);
assert_eq!(collector.pop(), Some(start));
assert_eq!(collector.pop(), None);
}
+16107
View File
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
use super::*;
fn layout(text: &str, width: usize) -> Vec<Segment> {
let mut cache = WrapCache::default();
cache.prepare((1, 0), width);
cache.segments(0, text).to_vec()
}
#[test]
fn words_and_long_words_preserve_every_source_cell() {
assert_eq!(
layout("one two three", 8),
vec![Segment { start: 0, end: 8 }, Segment { start: 8, end: 13 },]
);
let segments = layout("abcdefgh", 3);
assert_eq!(
segments,
vec![
Segment { start: 0, end: 3 },
Segment { start: 3, end: 6 },
Segment { start: 6, end: 8 },
]
);
assert_eq!(caret_segment(&segments, 3), 1);
assert_eq!(caret_segment(&segments, 8), 2);
}
#[test]
fn long_words_do_not_wrap_indentation_onto_an_empty_visual_row() {
for source in [
" abcdefghijk",
"\tabcdefghijk",
"head abcdefghijk",
] {
let expanded = source.replace('\t', " ");
let segments = layout(source, 8);
assert_eq!(segments.first().unwrap().start, 0);
assert_eq!(segments.last().unwrap().end, expanded.len());
assert!(segments.windows(2).all(|pair| pair[0].end == pair[1].start));
for segment in segments
.iter()
.filter(|segment| segment.start < segment.end)
{
assert!(
!expanded[segment.start..segment.end].trim().is_empty(),
"whitespace-only visual row {segment:?} in {source:?}"
);
}
}
}
#[test]
fn tabs_use_source_stops_and_unicode_graphemes_remain_intact() {
assert_eq!(
layout("ab\t界e\u{301} z", 4),
vec![
Segment { start: 0, end: 4 },
Segment { start: 4, end: 8 },
Segment { start: 8, end: 9 },
]
);
assert_eq!(
layout("界界界", 3),
vec![
Segment { start: 0, end: 2 },
Segment { start: 2, end: 4 },
Segment { start: 4, end: 6 },
]
);
assert_eq!(
layout(" \t ", 3),
vec![Segment { start: 0, end: 3 }, Segment { start: 3, end: 5 },]
);
}
#[test]
fn narrow_tabs_preserve_all_expanded_cells_at_original_stops() {
assert_eq!(
layout("\tX", 2),
[
Segment { start: 0, end: 2 },
Segment { start: 2, end: 4 },
Segment { start: 4, end: 5 },
]
);
assert_eq!(
layout("a\tX", 2),
[
Segment { start: 0, end: 1 },
Segment { start: 1, end: 3 },
Segment { start: 3, end: 5 },
Segment { start: 5, end: 5 },
]
);
for source in ["\t", "a\t", "ab\tX", "\t\tX"] {
let segments = layout(source, 1);
assert!(
segments
.iter()
.all(|segment| segment.end - segment.start <= 1)
);
assert!(segments.windows(2).all(|pair| pair[0].end == pair[1].start));
let end = source.graphemes(true).fold(0, |column, grapheme| {
column
+ if grapheme == "\t" {
TAB_WIDTH - column % TAB_WIDTH
} else {
1
}
});
assert_eq!(segments.last().unwrap(), &Segment { start: end, end });
}
}
#[test]
fn full_lines_have_an_insertion_cell_and_cache_invalidation_is_lazy() {
let mut cache = WrapCache::default();
cache.prepare((7, 2), 4);
assert_eq!(
cache.segments(90, "abcd"),
[Segment { start: 0, end: 4 }, Segment { start: 4, end: 4 },]
);
assert_eq!(caret_segment(cache.segments(90, "abcd"), 4), 1);
assert_eq!(cache.rows.len(), 1);
cache.prepare((7, 2), 4);
assert_eq!(cache.rows.len(), 1);
cache.prepare((7, 2), 8);
assert!(cache.rows.is_empty());
cache.segments(90, "abcd");
cache.prepare((7, 3), 8);
assert!(cache.rows.is_empty());
assert_eq!(layout("", 0), [Segment { start: 0, end: 0 }]);
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "tree-sitter-honey"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "Tree-sitter grammar for the Honey programming language"
repository = "https://github.com/valdemar/tree-sitter-honey"
[dependencies]
tree-sitter-language = "0.1"
[build-dependencies]
cc = "1"
+10
View File
@@ -0,0 +1,10 @@
fn main() {
println!("cargo:rerun-if-changed=src/parser.c");
println!("cargo:rerun-if-changed=src/tree_sitter/parser.h");
cc::Build::new()
.include("src")
.file("src/parser.c")
.std("c11")
.warnings(false)
.compile("tree-sitter-honey");
}
+142
View File
@@ -0,0 +1,142 @@
; Broad defaults; specific contexts below override them.
(identifier) @variable
; Comments and literals
(comment) @comment
(string_literal) @string
(multiline_string_literal) @string
(character_literal) @string.special
(integer_literal) @number
(float_literal) @number
(boolean_literal) @boolean
(null_literal) @constant.builtin
(undefined_literal) @constant.builtin
(unreachable_literal) @keyword
; Declarations
(function_declaration name: (identifier) @function)
(test_declaration name: (identifier) @function)
(record_declaration name: (identifier) @type)
(enum_declaration name: (identifier) @type)
(opaque_declaration name: (identifier) @type)
(distinct_declaration name: (identifier) @type)
(alias_declaration name: (identifier) @type)
(binding_declaration name: (identifier) @variable)
(binding_declaration name: (identifier) @constant operator: "::")
(parameter_group name: (identifier) @variable.parameter)
(parameter_group "$" . name: (identifier) @constant)
(parameter_group "$" @attribute)
(comptime_block "$" @attribute)
(conditional_capture name: (identifier) @variable.parameter)
(loop_capture item: (identifier) @variable.parameter)
(loop_capture index: (identifier) @variable.parameter)
(match_capture name: (identifier) @variable.parameter)
(inline_match_capture value: (identifier) @variable.parameter)
(inline_match_capture tag: (identifier) @variable.parameter)
(capture_list (identifier) @variable.parameter)
(record_field name: (member_name) @property)
(enum_member name: (member_name) @constant)
(import_declaration alias: (identifier) @constant @module)
; Types
(primitive_type) @type.builtin
(type_identifier) @type
(type_identifier (identifier) @type)
(type_call function: (type_identifier) @type)
(array_type length: (identifier) @constant)
((type_call
arguments: (argument_list
(identifier) @type))
(#match? @type "^[A-Z][A-Za-z0-9_]*$"))
; Expressions
(call_expression function: (identifier) @function)
(field_expression field: (member_name) @property)
(call_expression
function: (field_expression
field: (member_name) @function))
(field_expression field: (integer_literal) @property)
(record_initializer name: (member_name) @property)
(enum_literal name: (member_name) @constructor)
; Keywords
[
"test"
"proc"
"func"
"c_func"
"struct"
"c_struct"
"opaque"
"union"
"enum"
"distinct"
"alias"
"import"
"return"
"try"
"catch"
"mut"
"orelse"
"and"
"or"
"xor"
"if"
"while"
"for"
"inline"
"break"
"continue"
"defer"
"errdefer"
"yield"
"match"
"else"
] @keyword
(visibility_modifier ["hide" "file" "package"] @attribute)
; Operators
[
"::"
":="
"="
"+="
"-="
"*="
"/="
"&="
"|="
"xor="
"<<="
">>="
"<<|="
"+"
"-"
"*"
"/"
"=="
"!="
"<"
"<="
">"
">="
"&"
"|"
"<<"
">>"
"<<|"
".."
"..="
"!"
"~"
"?"
"^"
"@"
] @operator
(visibility_modifier "@" @attribute)
; Punctuation
["(" ")" "[" "]" "{" "}"] @punctuation.bracket
["," ":" ";" "."] @punctuation.delimiter
+8
View File
@@ -0,0 +1,8 @@
use tree_sitter_language::LanguageFn;
unsafe extern "C" {
fn tree_sitter_honey() -> *const ();
}
pub const LANGUAGE: LanguageFn = unsafe { LanguageFn::from_raw(tree_sitter_honey) };
pub const HIGHLIGHTS_QUERY: &str = include_str!("../queries/highlights.scm");
+1525077
View File
File diff suppressed because it is too large Load Diff
+286
View File
@@ -0,0 +1,286 @@
#ifndef TREE_SITTER_PARSER_H_
#define TREE_SITTER_PARSER_H_
#ifdef __cplusplus
extern "C" {
#endif
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#define ts_builtin_sym_error ((TSSymbol)-1)
#define ts_builtin_sym_end 0
#define TREE_SITTER_SERIALIZATION_BUFFER_SIZE 1024
#ifndef TREE_SITTER_API_H_
typedef uint16_t TSStateId;
typedef uint16_t TSSymbol;
typedef uint16_t TSFieldId;
typedef struct TSLanguage TSLanguage;
typedef struct TSLanguageMetadata {
uint8_t major_version;
uint8_t minor_version;
uint8_t patch_version;
} TSLanguageMetadata;
#endif
typedef struct {
TSFieldId field_id;
uint8_t child_index;
bool inherited;
} TSFieldMapEntry;
// Used to index the field and supertype maps.
typedef struct {
uint16_t index;
uint16_t length;
} TSMapSlice;
typedef struct {
bool visible;
bool named;
bool supertype;
} TSSymbolMetadata;
typedef struct TSLexer TSLexer;
struct TSLexer {
int32_t lookahead;
TSSymbol result_symbol;
void (*advance)(TSLexer *, bool);
void (*mark_end)(TSLexer *);
uint32_t (*get_column)(TSLexer *);
bool (*is_at_included_range_start)(const TSLexer *);
bool (*eof)(const TSLexer *);
void (*log)(const TSLexer *, const char *, ...);
};
typedef enum {
TSParseActionTypeShift,
TSParseActionTypeReduce,
TSParseActionTypeAccept,
TSParseActionTypeRecover,
} TSParseActionType;
typedef union {
struct {
uint8_t type;
TSStateId state;
bool extra;
bool repetition;
} shift;
struct {
uint8_t type;
uint8_t child_count;
TSSymbol symbol;
int16_t dynamic_precedence;
uint16_t production_id;
} reduce;
uint8_t type;
} TSParseAction;
typedef struct {
uint16_t lex_state;
uint16_t external_lex_state;
} TSLexMode;
typedef struct {
uint16_t lex_state;
uint16_t external_lex_state;
uint16_t reserved_word_set_id;
} TSLexerMode;
typedef union {
TSParseAction action;
struct {
uint8_t count;
bool reusable;
} entry;
} TSParseActionEntry;
typedef struct {
int32_t start;
int32_t end;
} TSCharacterRange;
struct TSLanguage {
uint32_t abi_version;
uint32_t symbol_count;
uint32_t alias_count;
uint32_t token_count;
uint32_t external_token_count;
uint32_t state_count;
uint32_t large_state_count;
uint32_t production_id_count;
uint32_t field_count;
uint16_t max_alias_sequence_length;
const uint16_t *parse_table;
const uint16_t *small_parse_table;
const uint32_t *small_parse_table_map;
const TSParseActionEntry *parse_actions;
const char * const *symbol_names;
const char * const *field_names;
const TSMapSlice *field_map_slices;
const TSFieldMapEntry *field_map_entries;
const TSSymbolMetadata *symbol_metadata;
const TSSymbol *public_symbol_map;
const uint16_t *alias_map;
const TSSymbol *alias_sequences;
const TSLexerMode *lex_modes;
bool (*lex_fn)(TSLexer *, TSStateId);
bool (*keyword_lex_fn)(TSLexer *, TSStateId);
TSSymbol keyword_capture_token;
struct {
const bool *states;
const TSSymbol *symbol_map;
void *(*create)(void);
void (*destroy)(void *);
bool (*scan)(void *, TSLexer *, const bool *symbol_whitelist);
unsigned (*serialize)(void *, char *);
void (*deserialize)(void *, const char *, unsigned);
} external_scanner;
const TSStateId *primary_state_ids;
const char *name;
const TSSymbol *reserved_words;
uint16_t max_reserved_word_set_size;
uint32_t supertype_count;
const TSSymbol *supertype_symbols;
const TSMapSlice *supertype_map_slices;
const TSSymbol *supertype_map_entries;
TSLanguageMetadata metadata;
};
static inline bool set_contains(const TSCharacterRange *ranges, uint32_t len, int32_t lookahead) {
uint32_t index = 0;
uint32_t size = len - index;
while (size > 1) {
uint32_t half_size = size / 2;
uint32_t mid_index = index + half_size;
const TSCharacterRange *range = &ranges[mid_index];
if (lookahead >= range->start && lookahead <= range->end) {
return true;
} else if (lookahead > range->end) {
index = mid_index;
}
size -= half_size;
}
const TSCharacterRange *range = &ranges[index];
return (lookahead >= range->start && lookahead <= range->end);
}
/*
* Lexer Macros
*/
#ifdef _MSC_VER
#define UNUSED __pragma(warning(suppress : 4101))
#else
#define UNUSED __attribute__((unused))
#endif
#define START_LEXER() \
bool result = false; \
bool skip = false; \
UNUSED \
bool eof = false; \
int32_t lookahead; \
goto start; \
next_state: \
lexer->advance(lexer, skip); \
start: \
skip = false; \
lookahead = lexer->lookahead;
#define ADVANCE(state_value) \
{ \
state = state_value; \
goto next_state; \
}
#define ADVANCE_MAP(...) \
{ \
static const uint16_t map[] = { __VA_ARGS__ }; \
for (uint32_t i = 0; i < sizeof(map) / sizeof(map[0]); i += 2) { \
if (map[i] == lookahead) { \
state = map[i + 1]; \
goto next_state; \
} \
} \
}
#define SKIP(state_value) \
{ \
skip = true; \
state = state_value; \
goto next_state; \
}
#define ACCEPT_TOKEN(symbol_value) \
result = true; \
lexer->result_symbol = symbol_value; \
lexer->mark_end(lexer);
#define END_STATE() return result;
/*
* Parse Table Macros
*/
#define SMALL_STATE(id) ((id) - LARGE_STATE_COUNT)
#define STATE(id) id
#define ACTIONS(id) id
#define SHIFT(state_value) \
{{ \
.shift = { \
.type = TSParseActionTypeShift, \
.state = (state_value) \
} \
}}
#define SHIFT_REPEAT(state_value) \
{{ \
.shift = { \
.type = TSParseActionTypeShift, \
.state = (state_value), \
.repetition = true \
} \
}}
#define SHIFT_EXTRA() \
{{ \
.shift = { \
.type = TSParseActionTypeShift, \
.extra = true \
} \
}}
#define REDUCE(symbol_name, children, precedence, prod_id) \
{{ \
.reduce = { \
.type = TSParseActionTypeReduce, \
.symbol = symbol_name, \
.child_count = children, \
.dynamic_precedence = precedence, \
.production_id = prod_id \
}, \
}}
#define RECOVER() \
{{ \
.type = TSParseActionTypeRecover \
}}
#define ACCEPT_INPUT() \
{{ \
.type = TSParseActionTypeAccept \
}}
#ifdef __cplusplus
}
#endif
#endif // TREE_SITTER_PARSER_H_