Compare commits

...

37 Commits

Author SHA1 Message Date
hl-valdemar 4099435175 feat: use two-column diagonal diff hatching (EXPERIMENT; DISCARDED) 2026-10-14 00:16:52 +02:00
hl-valdemar a8428f9dde docs: investigate two-column diff hatching 2026-10-14 00:09:42 +02:00
hl-valdemar cd427f74dc fix: replace selected characters with visual r 2026-10-14 00:09:42 +02:00
hl-valdemar 11b31d2621 feat: dim whitespace markers and configure scope and newlines 2026-10-13 23:33:42 +02:00
hl-valdemar adcc4f2656 fix: preserve literal whitespace inside strings 2026-10-13 23:12:57 +02:00
hl-valdemar 2357b5be46 feat: add configurable whitespace markers 2026-10-13 22:58:18 +02:00
hl-valdemar e2f33519d1 feat: add opt-in trailing whitespace highlighting 2026-10-13 22:53:32 +02:00
hl-valdemar b58fb085a3 feat: configure cursor distance from viewport edges 2026-10-13 22:42:23 +02:00
hl-valdemar 00628e0efd fix: stop hiding cached gutter markers while edits refresh 2026-10-13 22:10:03 +02:00
hl-valdemar 142b00118a fix: prioritize diagnostics over repository number colors 2026-10-13 21:54:10 +02:00
hl-valdemar 9526876b04 feat: show repository changes in normal file pickers 2026-10-13 21:46:53 +02:00
hl-valdemar e90d1c3945 fix: add spacing between line numbers and repository markers 2026-10-13 21:04:51 +02:00
hl-valdemar fd59c05f80 fix: separate gutter bars visually from line numbers 2026-10-13 20:50:50 +02:00
hl-valdemar 55cdee1a13 feat: add Git and Jujutsu gutter indicators 2026-10-13 20:46:44 +02:00
hl-valdemar e84d70463f feat: add counted word change actions
Add cw/cb and configurable core actions using the same line-local spans as dw/db. Enter Insert with grouped replacement undo and native dot repeat.
2026-10-13 17:43:13 +02:00
hl-valdemar 983f43e710 fix: keep counted word deletions within the current line
Word deletion reused cross-line word motion destinations. Constrain token traversal and boundary fallbacks to each caret’s current row while preserving cross-line navigation.
2026-10-13 17:35:09 +02:00
hl-valdemar 99fa5a2aff feat: track checkout identity for pinned diff editability 2026-10-13 17:28:59 +02:00
hl-valdemar e3e087f16f fix: use two columns between buffer IDs and filenames 2026-10-13 17:03:57 +02:00
hl-valdemar 33942b2671 fix: place current buffer marker before its ID 2026-10-13 16:38:14 +02:00
hl-valdemar 0eec3fc3b9 fix: refine buffer picker markers and paths 2026-10-13 16:31:53 +02:00
hl-valdemar 9d6a0eaeca feat: align buffer picker with editor UI 2026-10-13 16:31:53 +02:00
Valdemar Lorenzen f5ba61624f feat: add a searchable buffer picker 2026-10-13 14:30:36 +02:00
Valdemar Lorenzen bfdb7c544f feat: follow forward working-checkout changes in diffs 2026-10-13 13:49:10 +02:00
Valdemar Lorenzen b16232aa1d feat: jump to a line with counted g 2026-10-13 13:32:42 +02:00
Valdemar Lorenzen 8f87b8d3d1 feat: toggle diff file review with c in the file picker 2026-10-13 13:18:40 +02:00
Valdemar Lorenzen 38fcf86195 feat: mark diff files as reviewed 2026-10-13 13:16:57 +02:00
Valdemar Lorenzen c912abaf17 fix: yank whole lines with yy and preserve paste shape 2026-10-13 13:04:48 +02:00
Valdemar Lorenzen c4f72a45ba fix: center LSP definition destinations in the viewport 2026-10-13 12:56:52 +02:00
Valdemar Lorenzen e36c28c981 perf: cache file attribution for instant blame navigation 2026-10-13 12:41:46 +02:00
Valdemar Lorenzen 7a3c27c30e feat: show current-line git blame by default 2026-10-13 12:29:21 +02:00
Valdemar Lorenzen 5382307fcc feat: rank fuzzy revision and reference searches in diff selectors 2026-10-13 12:20:00 +02:00
Valdemar Lorenzen 002f071b0f fix: lead the bottom position indicator with one margin column 2026-10-13 12:12:44 +02:00
Valdemar Lorenzen 9f33104cb2 feat: add bar element margins and space LSP indicators 2026-10-13 11:23:51 +02:00
Valdemar Lorenzen b94739183d perf: avoid JXA startup for macOS clipboard writes 2026-10-13 11:20:02 +02:00
Valdemar Lorenzen 8c5d218c11 fix: dim etched diff areas to match gutter text 2026-10-13 11:03:58 +02:00
Valdemar Lorenzen d7fbf9a144 feat: add normal-mode change yank replace and word delete actions 2026-10-13 11:03:20 +02:00
Valdemar Lorenzen 6c254f9b0d feat: default diffs to branch changes 2026-10-13 10:48:24 +02:00
32 changed files with 13523 additions and 575 deletions
+2 -1
View File
@@ -35,8 +35,9 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal.
| 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 |
| 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 |
| 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 checkout 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 |
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.
+15 -20
View File
@@ -1,28 +1,23 @@
- [x] navigation control/actions: seek occurrence of character ('f' forwards, 'F' backwards by default; allow e.g. `2f` for seeking second occurrence instead of first). - [x] add option to highlight trailing whitespace (off by default)
- [x] buffer ux improvement: enable mouse scrolling sideways so longer lines are easier to follow and read (e.g. in case they extend beyond the width of the buffer view). - [x] add option to put characters at certain whitespace characters (off by default)
- [x] buffer ux improvement: add toggleable word wrap (no keybinding by default). - whitespace kind -> character map should be configurable.
- [x] add panes/splits: split current pane above/below (`_`) or left/right (`|`) in Normal mode; navigate panes with Option/Alt+h/j/k/l. - [x] diff view: use two-column diagonal hatching for missing counterparts (45° with 2:1 terminal cells)
- shared split layout and directional focus with diff views; independent pane views, shared buffers, and guarded close/force-close actions. - [ ] github/gitlab/gitea integration (probably requries authentication with github)
- [x] remove diff viewer's file explorer's `A added M modified...` explanation - it's standard and the explanation doesn't fit in the pane, so it can go. - github pr picker (floating window)
- no replacement help footer in diff file lists; reclaim its row for file entries. - three tabs: open, closed, all
- [x] diff view: add character-wise change highlight (enabled by default). - search under the given category
- [x] add tabs: tab support, editor action to open a buffer in a new tab, default tab navigation is `ctrl+option+h/l`, close tab action. - 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)
- per-tab pane layouts, views, explorers, and comparisons; shared buffers, tab bar, and guarded close/force-close actions. - [ ] cut down on the amount of documentation in the project (readme file is needlessly detailed and filled with irrelevant information).
- [x] make diff selector close on escape.
- [x] `:` currently overrides the diff selector input (and maybe other input fields) and spawns the command input on top. this is of course not intended behavior and should be fixed.
- [x] diff view: lsp actions should act as they normally would. if an lsp action would open another file, then the file should be opened in another tab outside the diff view - if the referenced file/symbol is part of the changes in the diff, it should just be opened in the diff view.
- changed symbol ranges stay in the comparison; other targets open ordinary tabs. Historical text stays immutable; source freshness and failure atomicity are preserved.
- [x] better syntax highlighting for markdown files. color `-` and `1.` from lists.
- [x] automatically reload buffers when underlying file changes outside of vamp if buffer is clean. if buffer is dirty, reject normal write attempts with warning and require forceful write to overwrite underlying file contents.
- [x] diff view: change diff view modes; choose between split and unified diff view (split by default; some key cycles; default should be configurable).
- Space v cycles split/unified; configurable startup default, dual line numbers, native side focus/editing, and shared highlighting/wrap/excerpts.
- [x] diff view: for split view, change split orientation (left/right vs above/below) - left/right by default.
- Space s flips orientation; configurable startup default and shared pane geometry/directional focus.
- [ ] 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: 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. - [ ] 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. - 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). - [ ] remove the diff palette polling fallback once a released Herdr fix delivers palette-ready DEC 2031 notifications, including palette-only changes with an unchanged light/dark preference. Related upstream issue: [herdrdev/herdr#4183](https://github.com/herdrdev/herdr/issues/4183).
- verify that notification-triggered queries return fresh colors in Ghostty through Herdr before removal; retain startup, focus-regain, and notification-driven palette refresh. - verify that notification-triggered queries return fresh colors in Ghostty through Herdr before removal; retain startup, focus-regain, and notification-driven palette refresh.
- [ ] pull a pr description and display it in a buffer:
- open a selector that allows you to pick a pr from remote
- selector should be searchable
- 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)
- [ ] sandboxed, revision-specific LSP analysis for historical diff states and historical tabs. - [ ] 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.
+14 -5
View File
@@ -11,8 +11,9 @@ 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.
- `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 file leaves retain their original catalog input index for caller-owned annotations; scanned entries and directories remain unannotated. 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.
- `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.
@@ -50,7 +51,9 @@ 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.
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). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned. 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.
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/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 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.
@@ -75,13 +78,19 @@ 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.
`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.
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.
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. Gutter width follows the total line count plus one separator cell, including an empty final row after a trailing newline. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Rows beyond the buffer have no number. 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.
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.
## Syntax caches ## Syntax caches
@@ -91,13 +100,13 @@ Diff highlighting lazily owns two instances of the same syntax cache, keyed by t
## Clipboard providers ## Clipboard providers
macOS uses fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`, preserving 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. Commands have a two-second deadline and bounded output; failures terminate/reap their 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.
## Native LSP ## Native LSP
The native module initializes each server, sends `initialization_options` as `initializationOptions`, answers `workspace/configuration` from `settings` (including dotted sections; missing sections return `null`), honors open/change/save capabilities, synchronizes in-memory text including hidden dirty buffers, and sends close events. Incremental servers receive whole-document range replacements. Process/pipe I/O and requests stay on bounded background workers; input and idle diagnostic redraw do not wait for server responses. The native module initializes each server, sends `initialization_options` as `initializationOptions`, answers `workspace/configuration` from `settings` (including dotted sections; missing sections return `null`), honors open/change/save capabilities, synchronizes in-memory text including hidden dirty buffers, and sends close events. Incremental servers receive whole-document range replacements. Process/pipe I/O and requests stay on bounded background workers; input and idle diagnostic redraw do not wait for server responses.
Diff snapshots retain a separate immutable document identity, full source text, side-specific path (the old path for renamed Before), and UTF-16 positions. Native definition updates preserve their UTF-16 ranges until the editor resolves the target's actual snapshot, dirty buffer, disk text, or lazily loaded comparison file; generic plugin navigation remains grapheme-based. Target ranges overlapping actual changed rows route within the same comparison and reveal the exact selection, including an unchanged declaration above a changed body. Wholly unchanged/context/omitted targets and files outside its changes open an ordinary tab without changing the original comparison or view. Snapshot-URI targets keep their immutable identity. Freshness includes tab, pane, source buffer/revision/caret/mode, and comparison/side; deferred file-load navigation also checks its originating view before moving focus. Invalid targets fail before opening a tab. Completion remains restricted to writable Working After. Diff snapshots retain a separate immutable document identity, full source text, side-specific path (the old path for renamed Before), and UTF-16 positions. Native definition updates preserve their UTF-16 ranges until the editor resolves the target's actual snapshot, dirty buffer, disk text, or lazily loaded comparison file; generic plugin navigation remains grapheme-based. Target ranges overlapping actual changed rows route within the same comparison and reveal the exact selection, including an unchanged declaration above a changed body. Wholly unchanged/context/omitted targets and files outside its changes open an ordinary tab without changing the original comparison or view. Snapshot-URI targets keep their immutable identity. Freshness includes tab, pane, source buffer/revision/caret/mode, and comparison/side; deferred file-load navigation also checks its originating view before moving focus. Invalid targets fail before opening a tab. Completion remains restricted to writable After, whether Working or a checkout-matched pinned endpoint.
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.
+58 -9
View File
@@ -13,6 +13,12 @@ Rejected configuration opens a centered floating **Configuration error** window,
Vamp.configure do |config| Vamp.configure do |config|
config.leader = "space" config.leader = "space"
config.clipboard_mode = "system" config.clipboard_mode = "system"
config.git_blame = true # false disables current-line attribution
config.git_gutter = "bar" # or "symbols", "line_numbers", "none"
config.scroll_distance = 0 # nonnegative edge distance, or nil to center
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\n" => "↵" }
config.whitespace_scope = "all" # or "leading" for indentation only
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"
@@ -20,6 +26,7 @@ Vamp.configure do |config|
config.diff_expand_lines = 15 config.diff_expand_lines = 15
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.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"
@@ -44,18 +51,45 @@ Plugin paths resolve relative to the config file, not the shell's working direct
`config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors. `config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
`config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration. `config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration.
`config.scroll_distance` defaults to `0`: keep the viewport still until its primary cursor or selected picker entry would leave it. A nonnegative integer reserves that many visible rows above/below the cursor, and terminal-cell columns left/right in unwrapped text. `nil` keeps it centered; integers at least half the current viewport dimension behave the same way until resizing makes that margin usable. The start of content limits centering, while trailing blank space allows centering near the end. Whole two-row picker entries stay aligned, using the nearest entry to the center when exact margins cannot fit.
The setting applies to ordinary and historical buffers, split/unified diff panes, file pickers and explorers, command/buffer palettes, completion lists, and revision selectors. Wrapped text measures visual rows, not source lines; picker distances use result rows, excluding borders, search fields, and help. Manual mouse-wheel scrolling remains detached until keyboard navigation resumes. Only `nil` or integers from `0` through the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems) are accepted; other values reject the configuration.
`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_scope` defaults to `"all"`. Use `"leading"` to mark only the initial whitespace prefix of each source line; a whitespace-only line is entirely leading, but wrapping and horizontal scrolling do not create new indentation. Only the strings `"all"` and `"leading"` are accepted; other values reject the configuration.
A `"\n"` map entry marks actual line breaks independently of `whitespace_scope`, including blank lines and a file's final newline when present. Soft wraps, missing final newlines, and the virtual empty EOF row do not gain markers. The glyph occupies the existing end-of-line caret cell on the final wrapped segment; a full-width line uses the existing extra caret segment. In unwrapped text, an offscreen end-of-line marker is clipped like other source cells. Newline markers inside syntax-recognized strings are suppressed along with horizontal whitespace markers.
Whitespace inside strings recognized by the bundled syntax grammar stays literal, including raw/multiline strings, heredocs, interpolated strings, and strings with embedded-language highlighting. Outside strings, configured markers still apply. String boundaries come from the original source, so wrapping, horizontal scrolling, and split/unified historical diffs retain this distinction. Plain text and unknown languages have no syntax-defined string boundaries; quotes are not guessed to be strings.
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.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.
In `"line_numbers"` mode, color precedence is **current-line cyan/bold → highest-severity diagnostic → repository change → default number color**. In bar/symbol modes, diagnostic number colors and repository markers remain independent.
Indicators compare the current editor text (including unsaved edits) against Git `HEAD`, or the parent `@-` in Git-backed jj repositories, rather than against the last save or Git index. Line endings use the editor's LF normalization. They refresh after edits/saves, on focus regain, and once per second for repository changes. Historical snapshots do not receive live indicators. Eligible named files not yet on disk show nonempty unsaved text as added; tracked files missing on disk still compare their buffer text against the baseline. Non-repository, ignored, binary, symlink, and unsupported files have no indicators; neither do jj working changes with multiple parents. Both colocated and non-colocated Git-backed jj repositories are supported. Files above 16 MiB report an error and clear their indicators; request batches are limited to 64 MiB or 1024 files.
`config.diff_context_lines = 10` sets how many unchanged aligned lines appear above and below each diff change; it defaults to `10` and accepts nonnegative integers, including `0` for changes only. `config.diff_expand_lines = 15` sets how many additional lines each directional expansion reveals per keypress; it defaults to `15` and accepts positive integers. Both are bounded by the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems). Floats, strings, booleans, negative or oversized values, and a zero expansion step reject the whole Ruby configuration. `config.diff_context_lines = 10` sets how many unchanged aligned lines appear above and below each diff change; it defaults to `10` and accepts nonnegative integers, including `0` for changes only. `config.diff_expand_lines = 15` sets how many additional lines each directional expansion reveals per keypress; it defaults to `15` and accepts positive integers. Both are bounded by the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems). Floats, strings, booleans, negative or oversized values, and a zero expansion step reject the whole Ruby configuration.
`config.diff_view_mode` selects the initial view for each new comparison: `"split"` (default) or `"unified"`. `config.diff_split_orientation` selects `"vertical"` (left/right, default) or `"horizontal"` (above/below) for split views. Invalid values reject the configuration. Normal-mode **Space v** cycles split/unified; **Space s** flips split orientation. These controls affect the current comparison, not startup defaults, and retain their choices across file switches and tabs. Orientation changes apply only in split view. `config.diff_view_mode` selects the initial view for each new comparison: `"split"` (default) or `"unified"`. `config.diff_split_orientation` selects `"vertical"` (left/right, default) or `"horizontal"` (above/below) for split views. Invalid values reject the configuration. Normal-mode **Space v** cycles split/unified; **Space s** flips split orientation. These controls affect the current comparison, not startup defaults, and retain their choices across file switches and tabs. Orientation changes apply only in split view.
`config.diff_follow_working_copy` defaults to `true`. A Working-checkout After follows descendant checkouts automatically, including in inactive tabs; `false` disables endpoint following without disabling ordinary live-buffer editing or reload. Only literal booleans are accepted. Pinned After endpoints never move, but their checkout-aware editability checks remain enabled regardless of this setting. See [repository diff](editing.md#repository-diff) for ancestry and refresh behavior.
```ruby ```ruby
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
config.bind "leader s", config.core.diff.orientation.toggle config.bind "leader s", config.core.diff.orientation.toggle
``` ```
### Top and bottom bars ### Top and bottom bars
`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, 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.
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.
@@ -67,7 +101,7 @@ 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_edge: "░▓", trailing_edge: "" } position: { padding: 1, leading_margin: 1, trailing_margin: 0, leading_edge: "░▓", trailing_edge: "" }
} }
config.bar.bottom = { config.bar.bottom = {
left: [:tabs], center: [], right: [:battery], left: [:tabs], center: [], right: [:battery],
@@ -90,9 +124,11 @@ Each segment's `title` is a literal template supporting only `{index}`, `{view}`
`: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.
`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` (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.
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, 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.
`:battery` displays `bat N%` from the OS, with an immediate background reading and refreshes every 30 seconds. macOS uses IOKit, Linux uses system battery sysfs entries, and Windows uses `GetSystemPowerStatus`; no shell commands run. Missing, unsupported, or unreadable batteries hide the segment. macOS/Linux currently report the first present internal/system battery; Windows uses the OS's system percentage. `:battery` displays `bat N%` from the OS, with an immediate background reading and refreshes every 30 seconds. macOS uses IOKit, Linux uses system battery sysfs entries, and Windows uses `GetSystemPowerStatus`; no shell commands run. Missing, unsupported, or unreadable batteries hide the segment. macOS/Linux currently report the first present internal/system battery; Windows uses the OS's system percentage.
@@ -145,7 +181,11 @@ Normal mode binds `ctrl+option+h/l` (`ctrl+alt+h/l`) to previous/next tab. Tab a
The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visual; `gf` follows Markdown file links in Normal only. Bind `config.core.document_start`, `config.core.document_end`, or `config.core.follow_link` to another key or sequence to add another route. A configured/disabled `g`, a configured sequence starting with `g`, or choosing `g` as the leader overrides the built-in prefix in that binding mode. The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visual; `gf` follows Markdown file links in Normal only. Bind `config.core.document_start`, `config.core.document_end`, or `config.core.follow_link` to another key or sequence to add another route. A configured/disabled `g`, a configured sequence starting with `g`, or choosing `g` as the leader overrides the built-in prefix in that binding mode.
Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `config.core.delete_line` or `config.core.change_line` to another key or sequence for another route. A configured/disabled `d` or `c`, a configured sequence starting with that key, or choosing it as the leader overrides its built-in doubled sequence. These are dedicated line actions, not general operator–motion grammar. With a decimal prefix, `g` instead jumps immediately to that one-based source line: `42g` goes to line 42, clamped at EOF, at first nonblank. Normal moves all carets; Visual extends selections. In diff text panes, explicit line jumps reveal folded source lines rather than counting only displayed excerpts. `g` without a count still begins `gt`, `gb`, or `gf`; a count followed by `g` is complete and does not await a suffix. Rebind the Normal/Visual native action `config.core.go_to_line` to use the numeric prefix with another key. Existing `g` bindings and prefixes override this default route.
Normal `yy` copies complete rows without moving the carets; `dd` / `cc` delete/change complete rows; `dw` / `db` delete forward/backward word spans within each caret's current logical line, preserving newlines. `cw` / `cb` cut the same spans and enter Insert. Leading and post-operator counts multiply and word edits stop at the line end/start. Bind `config.core.copy_line`, `config.core.delete_line`, `config.core.change_line`, `config.core.delete_word_forward`, `config.core.delete_word_backward`, `config.core.change_word_forward`, or `config.core.change_word_backward` to another key or sequence for another route. A configured/disabled operator key, a configured sequence starting with that key, or choosing it as the leader overrides its built-in operator prefix. These are dedicated actions, not general operator–motion grammar.
Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through `config.core.change_to_line_end`, `config.core.delete_to_line_end`, and `config.core.copy_to_line_end`. Normal and Visual `r` wait for a literal replacement character through `config.core.replace_character`: Normal honors the count; Visual replaces each selected grapheme without replacing newlines. Normal `s` deletes the current grapheme and enters Insert through `config.core.substitute_character`. Rebinding these actions preserves their native counts, Unicode boundaries, undo, and read-only guards.
`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.
@@ -156,13 +196,16 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con
| 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`, `command` |
| Normal | `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, Visual | `replace_character` |
| Normal | `save`, `force_save`, `quit`, `force_quit`, `restart`, `force_restart`, `save_and_quit`, `force_save_and_quit`, `plugins` | | Normal | `save`, `force_save`, `quit`, `force_quit`, `restart`, `force_restart`, `save_and_quit`, `force_save_and_quit`, `plugins` |
| 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.view.toggle`, `diff.orientation.toggle` | | Normal | `diff`, `diff.mode.toggle`, `diff.view.toggle`, `diff.orientation.toggle`, `diff.review.toggle` |
| Normal, Insert, Visual, Command, Search | `diff.close` | | Normal, Insert, Visual, Command, Search | `diff.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 | `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` |
| Normal | `tab.new`, `tab.previous`, `tab.next`, `tab.close`, `tab.force_close` | | Normal | `tab.new`, `tab.previous`, `tab.next`, `tab.close`, `tab.force_close` |
| Normal | `jump_back`, `jump_forward` | | Normal | `jump_back`, `jump_forward` |
@@ -173,7 +216,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con
| 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` | | Normal, Visual | `document_start`, `document_end`, `go_to_line` |
| Normal, Visual | `seek_forward`, `seek_backward` | | Normal, Visual | `seek_forward`, `seek_backward` |
| 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` |
@@ -195,6 +238,8 @@ Live clean buffers reload external changes automatically, including inactive tab
`core.quit` refuses unsaved changes in any buffer. `core.edit` and `core.buffer` open prefilled Ex prompts for their required path or buffer ID; deletion actions target the current buffer. Configured `core.force_quit`, `core.buffer_force_delete`, `core.pane.force_close`, and `core.tab.force_close` keys act immediately, without confirmation. `core.quit` refuses unsaved changes in any buffer. `core.edit` and `core.buffer` open prefilled Ex prompts for their required path or buffer ID; deletion actions target the current buffer. Configured `core.force_quit`, `core.buffer_force_delete`, `core.pane.force_close`, and `core.tab.force_close` keys act immediately, without confirmation.
`config.core.buffers` opens the searchable buffer picker in Normal mode, also available as `:buffers` and default **Space b l**. Its navigation uses `config.modes.selection`, like the command palette. For example, `config.bind "leader b b", config.core.buffers` adds another opening shortcut. Search, acceptance, and cancellation are described under [Buffers](editing.md#buffers).
`core.restart` and `core.force_restart` have no default keybindings. Their no-argument commands are `:restart` and `:force_restart`, with `:restart!` as an alias for force restart. Normal restart closes clean buffers across every tab, pane, and historical view; any dirty buffer blocks the relaunch with a warning, retains its text and undo/redo history, and leaves the editor interactive without restoring the clean buffers already closed. Force restart discards unsaved text without writing it. The palette confirms force restart, while a configured `config.core.force_restart` binding executes directly. `core.restart` and `core.force_restart` have no default keybindings. Their no-argument commands are `:restart` and `:force_restart`, with `:restart!` as an alias for force restart. Normal restart closes clean buffers across every tab, pane, and historical view; any dirty buffer blocks the relaunch with a warning, retains its text and undo/redo history, and leaves the editor interactive without restoring the clean buffers already closed. Force restart discards unsaved text without writing it. The palette confirms force restart, while a configured `config.core.force_restart` binding executes directly.
Restart relaunches the same executable with the original CLI arguments after full session teardown. Raw mode and input reporting briefly reset, but the alternate screen remains active across the process handoff; the new process resumes input modes and clears and repaints the screen atomically in its first frame. It rereads configuration and the original startup file (or opens an unnamed buffer when no file was supplied), rather than restoring current buffers, tabs, views, or session state. Final quit, errors, and panics restore normal input modes, the normal screen, cursor visibility, and the prior panic hook. Restart relaunches the same executable with the original CLI arguments after full session teardown. Raw mode and input reporting briefly reset, but the alternate screen remains active across the process handoff; the new process resumes input modes and clears and repaints the screen atomically in its first frame. It rereads configuration and the original startup file (or opens an unnamed buffer when no file was supplied), rather than restoring current buffers, tabs, views, or session state. Final quit, errors, and panics restore normal input modes, the normal screen, cursor visibility, and the prior panic hook.
@@ -235,9 +280,11 @@ config.bind "ctrl+f", config.core.right, mode: config.modes.tree
config.bind "ctrl+h", nil, mode: config.modes.tree config.bind "ctrl+h", nil, mode: config.modes.tree
``` ```
The diff's changed-file explorer and floating picker use these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's comparison; Working checkout After reuses its editable buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan. The diff's changed-file explorer and floating picker use these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's comparison; writable After reuses its live buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan.
Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Escape closes the revision selector in either stage, but an opened comparison still closes explicitly through `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and exposes immutable `close`, `view.toggle`, and `orientation.toggle` children. Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Escape closes the revision selector in either stage, but an opened comparison still closes explicitly through `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and exposes immutable `close`, `mode.toggle`, `view.toggle`, and `orientation.toggle` children. New comparisons default to Branch changes (merge base → selected After); Normal-mode Ctrl+b, `:diff.mode.toggle`, or the palette toggles to direct Snapshots and back, preserving the selected endpoints. Selecting Working checkout on either side forces Snapshots.
`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.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.
@@ -328,6 +375,8 @@ See [native LSP architecture](architecture.md#native-lsp) for synchronization an
`:lsp.definition` or the configured Space d binding requests a definition at the primary caret, once regardless of selection count. Both Location and LocationLink results are supported; multiple results use the first. Local-file navigation reuses loaded buffers without overwriting unsaved text. LSP UTF-16 positions and percent-encoded file URIs are translated to validated grapheme coordinates. Navigation is ignored if its source buffer, revision, caret, or latest request has changed. `:lsp.definition` or the configured Space d binding requests a definition at the primary caret, once regardless of selection count. Both Location and LocationLink results are supported; multiple results use the first. Local-file navigation reuses loaded buffers without overwriting unsaved text. LSP UTF-16 positions and percent-encoded file URIs are translated to validated grapheme coordinates. Navigation is ignored if its source buffer, revision, caret, or latest request has changed.
Successful definition jumps center the destination caret's visual row once in its actual text pane, excluding bars and docks. This applies to ordinary buffers, new destination tabs, wrapped text, and split/unified diff panes. Near the beginning of the file, centering clamps to the first line; near EOF, blank space can remain below the centered target. Subsequent cursor motion resumes ordinary scrolling. Failed or stale requests do not request centering; Markdown links, search, and jump-history navigation retain their existing behavior.
For programmatic bindings, `config.core.lsp` exposes immutable Normal-mode Action objects `definition`, `diagnostics`, and `hover`, plus the **Insert-only** `completion` Action. Normal actions route to their corresponding Ex commands and have no default keys. Completion defaults to Insert Ctrl+n; override or disable it using an Insert binding. It requests directly without leaving Insert or committing pending typing; there is no Normal `:lsp.completion` command. These actions are also available in the command palette according to mode eligibility. The namespace is distinct from `config.lsp[language_id]` server configuration. For programmatic bindings, `config.core.lsp` exposes immutable Normal-mode Action objects `definition`, `diagnostics`, and `hover`, plus the **Insert-only** `completion` Action. Normal actions route to their corresponding Ex commands and have no default keys. Completion defaults to Insert Ctrl+n; override or disable it using an Insert binding. It requests directly without leaving Insert or committing pending typing; there is no Normal `:lsp.completion` command. These actions are also available in the command palette according to mode eligibility. The namespace is distinct from `config.lsp[language_id]` server configuration.
Interactive LSP commands use dotted names (`:lsp.hover`, `:lsp.definition`, `:lsp.diagnostics`). The existing whitespace separator keeps the whole dotted name separate from arguments. Current LSP commands take no arguments. Interactive LSP commands use dotted names (`:lsp.hover`, `:lsp.definition`, `:lsp.diagnostics`). The existing whitespace separator keeps the whole dotted name separate from arguments. Current LSP commands take no arguments.
+5
View File
@@ -55,6 +55,10 @@ Clipboard checks use isolated providers rather than reading or overwriting the d
Diff checks cover staged category/filter navigation, stale request cancellation, row alignment, read-only buffer isolation, and rendered panes. Repository integration fixtures exercise Git and Git-backed jj, advertised but unfetched remote branches/bookmarks/tags, imported historical commit/change IDs, conflicts, unusual paths, binary/mode/link/submodule changes, and unchanged active metadata. Remote fixtures are local bare copies; they never push. Git fixture identities and commit dates are fixed. Diff checks cover staged category/filter navigation, stale request cancellation, row alignment, read-only buffer isolation, and rendered panes. Repository integration fixtures exercise Git and Git-backed jj, advertised but unfetched remote branches/bookmarks/tags, imported historical commit/change IDs, conflicts, unusual paths, binary/mode/link/submodule changes, and unchanged active metadata. Remote fixtures are local bare copies; they never push. Git fixture identities and commit dates are fixed.
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.
Normal-picker checks cover staged/disk/unsaved status, binary and mode changes, exact and ambiguous renames, deleted paths, unborn repositories, aliases and unusual filenames, ignores and safe roots, linked worktrees, and read-only colocated/non-colocated/conflicted jj parents. Catalog/render checks retain unchanged rows, query/selection/expansion and Tree/List navigation, clear stale metadata, restore recreated files, and sanitize rename labels. Live PTY smoke checks A/M/D/R colors in floating and docked pickers with the line gutter disabled, Git HEAD/jj parent refresh, and unchanged jj operation state during polling.
Diff LSP integration checks reuse the real framed-protocol subprocess with test-controlled response/publication gates and local Git comparisons. Cover both immutable historical sides and writable Working After, snapshot text/URI isolation, Unicode UTF-16 requests/targets/diagnostics, normal overlong-column clamping, hover, completion/undo, and read-only editing guards. Definition routing must distinguish ranges overlapping actual changed rows from wholly unchanged/context/omitted targets, use full location-link target ranges with exact selection reveal and exclusive endpoints, route canonical aliases and old/new rename paths, load other changed files on demand, handle added/deleted files, preserve original tabs/panes/excerpts and shared dirty targets, retain snapshot identity in ordinary tabs, and fail atomically for missing/non-UTF-8 files, invalid ranges, and unsupported URIs. Delayed/superseded replies must remain stale across caret moves, edit/undo, side/file/pane/tab changes, and comparison closure/replacement; publications for disposed snapshot identities must not reach their replacements. Private editor checks gate pending repository-load cancellation and exact-row reveal without timing assumptions. Before marking the TODO complete, run native integration/unit checks and an actual terminal smoke of focused diff LSP actions, changed-line routing, external new tabs, and immutable/editable side behavior. Diff LSP integration checks reuse the real framed-protocol subprocess with test-controlled response/publication gates and local Git comparisons. Cover both immutable historical sides and writable Working After, snapshot text/URI isolation, Unicode UTF-16 requests/targets/diagnostics, normal overlong-column clamping, hover, completion/undo, and read-only editing guards. Definition routing must distinguish ranges overlapping actual changed rows from wholly unchanged/context/omitted targets, use full location-link target ranges with exact selection reveal and exclusive endpoints, route canonical aliases and old/new rename paths, load other changed files on demand, handle added/deleted files, preserve original tabs/panes/excerpts and shared dirty targets, retain snapshot identity in ordinary tabs, and fail atomically for missing/non-UTF-8 files, invalid ranges, and unsupported URIs. Delayed/superseded replies must remain stale across caret moves, edit/undo, side/file/pane/tab changes, and comparison closure/replacement; publications for disposed snapshot identities must not reach their replacements. Private editor checks gate pending repository-load cancellation and exact-row reveal without timing assumptions. Before marking the TODO complete, run native integration/unit checks and an actual terminal smoke of focused diff LSP actions, changed-line routing, external new tabs, and immutable/editable side behavior.
Changed-file explorer checks cover reuse of the compact directory tree, removed/historical paths, file-to-directory transitions, remapped tree/selection bindings, filter/open/focus transitions, right-dock geometry, and wheel routing without edits to the underlying document. Changed-file explorer checks cover reuse of the compact directory tree, removed/historical paths, file-to-directory transitions, remapped tree/selection bindings, filter/open/focus transitions, right-dock geometry, and wheel routing without edits to the underlying document.
@@ -69,5 +73,6 @@ Diff styling checks cover independent snapshot language/shebang/injection detect
- 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 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.
- 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.
+57
View File
@@ -0,0 +1,57 @@
# Diff hatching: two-column diagonal feasibility
## Finding
**Yes: Unicode has the exact two line segments needed.** Repeat the following pair, in this order, with each character occupying one terminal column:
| Position | Character | Code point | Official Unicode name |
| --- | --- | --- | --- |
| Left cell | 🯐 | U+1FBD0 | BOX DRAWINGS LIGHT DIAGONAL MIDDLE RIGHT TO LOWER LEFT |
| Right cell | 🯑 | U+1FBD1 | BOX DRAWINGS LIGHT DIAGONAL UPPER RIGHT TO MIDDLE LEFT |
These are listed under **Character cell diagonals** on page 6 of the [official Unicode 17.0 Symbols for Legacy Computing chart](https://www.unicode.org/Public/17.0.0/charts/PDF/U1FB00.pdf); the exact names are also in [UnicodeData.txt](https://www.unicode.org/Public/17.0.0/ucd/UnicodeData.txt). They were introduced in **Unicode 16.0** (`1FBCB..1FBEF` in [DerivedAge.txt](https://www.unicode.org/Public/17.0.0/ucd/DerivedAge.txt)). The names specify endpoints, not a drawing direction: together they join the pair's bottom-left corner to the shared edge's midpoint, then to the pair's top-right corner.
Sample (glyphs may be missing or differently rendered in the Markdown viewer):
```text
🯐🯑🯐🯑🯐🯑🯐🯑
🯐🯑🯐🯑🯐🯑🯐🯑
```
Equivalent escaped sequence: `\u{1FBD0}\u{1FBD1}`. This is two separate characters/cells, not one double-width character.
## Geometry
For physical cell width `W` and height `H`, the ideal joined stroke has horizontal run `2W` and vertical rise `H`, so its angle above horizontal is:
```text
θ = atan(H / (2W))
```
This is **45° iff `H = 2W`**. The current full-cell `╱` has ideal angle `atan(H / W)`; at `H = 2W`, that is approximately 63.4°. Thus the pair is shallower, but the actual angle depends on the terminal's cell dimensions, including line spacing. These are geometric deductions from the chart's endpoints, not measured rendering results.
Do not substitute U+1FBA0–U+1FBAE: their centre/cell-edge endpoints describe different segments (see page 5 of the same chart). In particular, a side midpoint to top/bottom centre has run `W/2` and rise `H/2`, preserving the full-cell diagonal's slope rather than halving it.
## Line segments, not filled mosaics
U+1FBD0/U+1FBD1 are **light box-drawing strokes**. The chart separately labels U+1FB3C–U+1FB6F as **Smooth mosaic terminal graphic characters**: their `BLOCK DIAGONAL` names describe filled regions bounded by diagonals, not thin lines. For example, U+1FB48 is LOWER RIGHT BLOCK DIAGONAL LOWER LEFT TO LOWER MIDDLE RIGHT and U+1FB42 is LOWER RIGHT BLOCK DIAGONAL UPPER MIDDLE LEFT TO UPPER RIGHT ([chart, pages 3–4](https://www.unicode.org/Public/17.0.0/charts/PDF/U1FB00.pdf), [UnicodeData.txt](https://www.unicode.org/Public/17.0.0/ucd/UnicodeData.txt)). Those are not equivalent replacements for light hatching.
## Support limits and current code
- Unicode 16.0 repertoire support is a prerequisite; the existence of these characters does not establish coverage in the selected terminal/font. Missing glyphs or fallback glyphs remain possible, and fallback geometry must be checked in the intended renderer. Unicode explicitly says its chart glyph shapes are **not prescriptive** and actual fonts may vary ([chart, page 1](https://www.unicode.org/Public/17.0.0/charts/PDF/U1FB00.pdf)). Pixel-perfect joins or 45° rendering cannot be promised.
- The pair has East Asian Width **Neutral (`N`)**, from `1FB94..1FBEF` in [EastAsianWidth.txt](https://www.unicode.org/Public/17.0.0/ucd/EastAsianWidth.txt). This is not a terminal-width guarantee: [UAX #11, §2 and §4](https://www.unicode.org/reports/tr11/) explicitly requires terminal-specific tailoring and distinguishes font/layout width from the property. Both the application and terminal must agree on one column per glyph.
- Rendering now alternates `🯐` and `🯑`, starting at each pane's left content edge, with `DarkGray` foreground and reset background in [src/tui.rs:3308–3316](../src/tui.rs#L3308-L3316). The phase stays fixed during scrolling; odd widths clip the final pair. Actual empty source lines remain unhatched. See [docs/editing.md:426](editing.md#L426).
## Terminal support
- **Ghostty 1.3.1:** The [released drawing source](https://github.com/ghostty-org/ghostty/blob/v1.3.1/src/font/sprite/draw/symbols_for_legacy_computing.zig#L1007-L1031) explicitly implements both segments with the required endpoints. Ghostty's [codepoint resolver](https://github.com/ghostty-org/ghostty/blob/main/src/font/CodepointResolver.zig#L120-L145) normally selects procedural sprite rendering before ordinary font lookup, so this does not require font coverage; explicit codepoint font overrides take precedence.
- **Kitty:** [Current font dispatch](https://github.com/kovidgoyal/kitty/blob/master/kitty/fonts.c#L838-L886) routes this range to native box drawing, and [drawing source](https://github.com/kovidgoyal/kitty/blob/master/kitty/decorations.c#L2759-L2774) explicitly implements the two segments. This establishes support in inspected upstream source, not a minimum released version.
- The local environment reported Herdr 0.9.3; installed binaries reported Ghostty 1.3.1 and Kitty 0.45.0. Herdr [runs inside an outer terminal](https://github.com/herdrdev/herdr), so its presence and these installations do not prove which renderer displays this session. Visual verification through the actual terminal/multiplexer remains necessary.
**Recommendation:** Use the two-character text tile if it looks correct in the target terminal. No image protocol or new dependency is needed. Older/other terminals may lack glyph coverage; exact 45° remains conditional on cell geometry.
## Runtime evidence
An executed disposable `rustc` smoke program used the repository's actual compiled `ratatui` and `unicode-width` dependencies, a fixed viewport, `Terminal<CrosstermBackend<Vec<u8>>>`, and the same per-cell `set_symbol`/style logic as the current renderer. Both U+1FBD0 and U+1FBD1 returned width `Some(1)`. At three rows high, widths 1, 2, 3, and 12 emitted exactly 3, 6, 9, and 36 glyphs respectively: no dropped or double-width cells. This establishes application-side width/emission behavior, **not** actual terminal glyph coverage, pixel joins, or the visible angle; those were not visually verified. The disposable program and binary were removed.
Implemented with no new dependency or graphics protocol. Verification: `cargo test diff_` (94 passing checks), `cargo build`, and `cargo fmt --package vamp --check`. A live PTY session opened `:diff` against a disposable jj baseline with two removed rows and emitted paired hatching at 100, 101, and 77 terminal columns; no U+2571 was emitted, the editor exited cleanly, and fixture content stayed unchanged. The fixture was removed. Terminal pixel joins remain visually unverified. The TODO item is complete.
+72 -14
View File
@@ -4,13 +4,19 @@
Default controls and editing behavior. For custom bindings, see [configuration](configuration.md); for language-server commands, see [native LSP](configuration.md#native-lsp). Default controls and editing behavior. For custom bindings, see [configuration](configuration.md); for language-server commands, see [native LSP](configuration.md#native-lsp).
Ordinary documents show repository changes beside their hybrid line numbers by default: green `▌` for additions, red for removals, and yellow for modifications. [Configure `git_gutter`](configuration.md) to use `+` / `-` / `~`, colored numbers, or no change indicators. Bar/symbol gutters keep the order **number, blank, marker, blank, text**; number-only modes use no extra column. Horizontal scrolling never moves indicators, and wrapped continuation rows do not repeat them.
The baseline is Git `HEAD` or Git-backed jj's parent `@-`, so indicators include unsaved edits and remain after saving until the repository baseline changes. A deletion marks the next surviving source row; at EOF it marks the last editor row, including the final empty row after a trailing newline. An empty file anchors removals on row 0. Replacements mark paired rows modified and extra current rows added. Historical snapshots do not show live changes. Eligible named files not yet on disk show nonempty unsaved text as added; tracked files missing on disk still compare their buffer text against the baseline. Non-repository/ignored/binary/symlink files, files over 16 MiB, and jj changes with multiple parents have no indicators.
Indicators refresh asynchronously. Typing and undo/redo retain the last completed indicators until the updated comparison arrives, avoiding flashes; indicator values and row positions can briefly lag edits.
## Default editing keys ## Default editing keys
| Mode | Keys | | Mode | Keys |
| --- | --- | | --- | --- |
| All modes | Ctrl+p opens the command palette; type to filter, Ctrl+j/k or arrows select, Enter accepts, Esc/Ctrl+p cancels | | All modes | Ctrl+p opens the command palette; type to filter, Ctrl+j/k or arrows select, Enter accepts, Esc/Ctrl+p cancels |
| Normal, Visual | Space Space opens the palette through the default leader | | Normal, Visual | Space Space opens the palette through the default leader |
| Normal | Space w saves; Space b n/p/l/d switches next/previous, lists, or closes buffers | | Normal | Space w saves; Space b n/p/l/d switches next/previous, opens the buffer picker, or closes buffers |
| Normal, Visual | Space o opens the floating tree in INSERT/filter mode; Space e opens/focuses the docked compact tree | | Normal, Visual | Space o opens the floating tree in INSERT/filter mode; Space e opens/focuses the docked compact tree |
| Normal | `i` insert before each caret; `a` insert after each caret; `o`/`O` open lines below/above | | Normal | `i` insert before each caret; `a` insert after each caret; `o`/`O` open lines below/above |
| Normal, Visual | `I` (Shift+i) / `A` (Shift+a) insert at first nonblank / line end; Visual splits each touched row into a caret first | | Normal, Visual | `I` (Shift+i) / `A` (Shift+a) insert at first nonblank / line end; Visual splits each touched row into a caret first |
@@ -22,11 +28,17 @@ Default controls and editing behavior. For custom bindings, see [configuration](
| Normal, Visual | `f<char>` seeks forward on the current line; `F<char>` seeks backward; `2fx` finds the second `x` after each caret | | Normal, Visual | `f<char>` seeks forward on the current line; `F<char>` seeks backward; `2fx` finds the second `x` after each caret |
| 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 | 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/Tab goes forward; 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 | `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 delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
| Normal | `dd` deletes whole lines; `cc` replaces them and enters Insert; leading counts work (`3dd`, `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, Visual | `r<char>` replaces the current grapheme in Normal or every selected grapheme in Visual |
| Normal | `s` deletes the current grapheme and enters Insert |
| 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, 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; `n` finds the next match; `N` (Shift+n) finds the 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 |
@@ -85,16 +97,25 @@ Counts apply to `h`/`j`/`k`/`l`, arrows, `w`/`b`/`e`, `W`/`B`/`E`, `f<char>`/`F<
Unbound ASCII digits `1`–`9` start a count in Normal/Visual; subsequent digits extend it. `0` retains line-start behavior without a count and extends an existing count when bound to line-start. Explicit digit bindings, including disabled keys, take precedence. Count arithmetic saturates rather than overflowing; large counts stop at motion boundaries or wrap for search, without executing an unbounded repeat loop. Unbound ASCII digits `1`–`9` start a count in Normal/Visual; subsequent digits extend it. `0` retains line-start behavior without a count and extends an existing count when bound to line-start. Explicit digit bindings, including disabled keys, take precedence. Count arithmetic saturates rather than overflowing; large counts stop at motion boundaries or wrap for search, without executing an unbounded repeat loop.
The status bar displays a pending count. Esc cancels it without moving, collapsing carets, or leaving Visual; the next Esc performs its ordinary action. Leader and `dd`/`cc` prefixes retain the count until their action resolves. Line actions use that count; other non-motion commands consume it and execute once, including line-start/end, other edits, and plugins. Unknown/disabled keys and bracketed paste clear it; opening the palette clears it, so palette query digits remain literal. Digits also remain literal in Insert and command/search prompts. Counts add no history, and general Vim operator grammar such as `3dw` is not supported. The status bar displays a pending count. Esc cancels it without moving, collapsing carets, or leaving Visual; the next Esc performs its ordinary action. Leader and editing prefixes retain the count until their action resolves. Line/word deletion, character replacement, and substitution use counts; suffix actions and other non-motion commands consume it and execute once. Unknown/disabled keys and bracketed paste clear it; opening the palette clears it, so palette query digits remain literal. Digits also remain literal in Insert and command/search prompts. Counts add no history; only the dedicated operators described below are supported.
## Normal line actions ## Normal editing actions
`dd` cuts the current line and its newline into the configured linewise clipboard, staying in Normal. `cc` cuts the same whole-row contents, leaves one empty replacement row, and enters Insert at column 0; indentation is not retained. Leading counts select rows downward from each caret, clamped at EOF: `3dd` cuts up to three rows and `2cc` changes up to two rows. Overlapping ranges merge before editing or clipboard publication; adjacent ranges remain separate targets. Deleting at EOF removes the preceding separator when needed, and the buffer always retains at least one row. `dd` cuts the current line and its newline into the configured linewise clipboard, staying in Normal. `cc` cuts the same whole-row contents, leaves one empty replacement row, and enters Insert at column 0; indentation is not retained. Leading counts select rows downward from each caret, clamped at EOF: `3dd` cuts up to three rows and `2cc` changes up to two rows. Overlapping ranges merge before editing or clipboard publication; adjacent ranges remain separate targets. Deleting at EOF removes the preceding separator when needed, and the buffer always retains at least one row.
The first `d` or `c` waits for its matching second key and appears in the status bar. Escape cancels only that pending prefix/count, preserving carets; an unmatched key cancels the prefix and then performs its ordinary action. Only leading counts are supported, not forms such as `d2d` or `d{motion}`. Explicit bindings, disabled first keys, and a leader using that first key take precedence. `yy` copies the entire current line into the configured linewise clipboard without moving any caret, changing text, or replacing undo/redo and dot-repeat history. Counts before and after `y` multiply (`2yy`, `y2y`, `2y3y`), selecting rows downward and clamping at EOF. Empty and unterminated final rows retain linewise shape. `p` pastes these complete rows below the current line; `P` pastes above it. The caret lands on the first nonblank of the first inserted row. Characterwise yanks still paste after/before the current grapheme. Bind `config.core.copy_line` for another Normal-mode route; historical read-only buffers permit yanking but reject paste.
The first `d`, `c`, or `y` waits for its second key and appears in the status bar. `d` accepts `d`, `w`, or `b`; `c` accepts `c`, `w`, or `b`; `y` accepts `y`. Counts before and after the operator multiply: `2d3w` deletes six forward word spans, `2c3b` cuts six backward spans and enters Insert, and `d2d` deletes two rows. Escape cancels only the pending prefix/count, preserving carets; an unmatched key cancels the prefix and then performs its ordinary action. Explicit bindings, disabled first keys, and a leader using that first key take precedence.
System clipboard publication must succeed before any deletion. Failure preserves text, carets, mode, undo/redo, and repeat intent. `dd` is one undo action; `cc` and subsequent replacement typing form one Insert undo group. Dot repeats the original requested row count at the current carets—even if the original action was clamped at EOF—and replays `cc` replacement text. Rebind the Normal-only `config.core.delete_line` / `config.core.change_line` actions to a single key or leader sequence without relying on the built-in doubled keys. System clipboard publication must succeed before any deletion. Failure preserves text, carets, mode, undo/redo, and repeat intent. `dd` is one undo action; `cc` and subsequent replacement typing form one Insert undo group. Dot repeats the original requested row count at the current carets—even if the original action was clamped at EOF—and replays `cc` replacement text. Rebind the Normal-only `config.core.delete_line` / `config.core.change_line` actions to a single key or leader sequence without relying on the built-in doubled keys.
`D` cuts from each caret through its logical line end; `C` cuts the same suffix and enters Insert. `Y` copies that suffix characterwise without moving the carets. These actions leave line separators intact and ignore counts. `C` and subsequent typed replacement form one undo group.
`r<char>` replaces one grapheme per caret and stays in Normal; `s` cuts one grapheme and enters Insert. Counts select that many graphemes, clamped to the current line. Combining sequences and ZWJ emoji are never split. Replacement accepts a printable character or Tab literally; Escape or other non-character input cancels it without editing. Empty-line replacement is a no-op; substitution can still enter Insert. `s` and replacement typing form one undo group.
`dw` / `db` cut characterwise spans from each caret to the forward/backward word-start boundary, using the same Unicode word boundaries as `w` / `b`, but confined to that caret's current logical line. Counts repeat the boundary motion, stopping at the line end/start without deleting its newline or touching neighboring lines; ordinary `w` / `b` navigation still crosses lines. Backward spans exclude the original caret character. Empty spans do not edit text. These are dedicated actions, not arbitrary operator–motion grammar. Suffix, substitution, and word cuts share the configured clipboard's failure-before-edit protection; all native edits support undo and dot repeat.
`cw` / `cb` cut exactly the same line-local spans as `dw` / `db`, including intervening whitespace, then enter Insert at each cut's start. Counts before and after `c` multiply (`2cw`, `c2w`, `2c3b`); excess counts stop at the current line's boundary. Empty spans still enter Insert, including on empty lines and for `cb` at column zero. The cut and subsequent replacement typing form one undo group; dot repeat recomputes the requested spans at the current carets and replays the replacement text. Rebind the Normal-only `config.core.change_word_forward` / `config.core.change_word_backward` actions for another route.
## Selections and multi-caret editing ## Selections and multi-caret editing
@@ -106,7 +127,9 @@ Copy and cut return to Normal at each selection start; Visual cancellation keeps
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.
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 `dd` and `cc`, but not general operator grammar such as `cw`, `dw`, or `yy`; blockwise Visual is also unsupported. `c` and `d` type literal text in Insert mode. 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.
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 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.
@@ -126,7 +149,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.
System clipboard access supports macOS through `osascript`, Linux Wayland through `wl-paste`/`wl-copy`, and Linux X11 through `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 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.
## Repeat last edit ## Repeat last edit
@@ -182,6 +205,10 @@ Live file buffers automatically check disk metadata every 250 ms, including buff
Paste is backend input: it is ignored in Normal, Command, Search, and Visual modes, and inserts literal text in Insert mode, with CRLF/CR normalization and a 4 MiB limit. Pasted text is one undo action, separate from typing before and after it. Rejected paste reports an error without changing text, cursor, mode, or pending insert history. Internal clipboard actions are separate from `Input::Paste` and use already-validated buffer text. Paste is backend input: it is ignored in Normal, Command, Search, and Visual modes, and inserts literal text in Insert mode, with CRLF/CR normalization and a 4 MiB limit. Pasted text is one undo action, separate from typing before and after it. Rejected paste reports an error without changing text, cursor, mode, or pending insert history. Internal clipboard actions are separate from `Input::Paste` and use already-validated buffer text.
## Cursor-following scrolling
[Configure `scroll_distance`](configuration.md#preferences) to reserve space between the primary cursor or picker selection and the viewport edges. `0` (default) scrolls only to keep it visible; an integer reserves that many visual rows, and columns for unwrapped document text. `nil`, or a margin too large for the current viewport, centers it as far as the start of content permits. This policy applies to documents, historical/diff panes, file explorers, palettes, completion lists, and revision pickers. Reversing direction inside the margin does not move the viewport; resizing recalculates the usable margin.
## Mouse wheel scrolling ## Mouse wheel scrolling
Mouse-wheel up/down scrolls the document view three display rows per event without moving the primary or secondary carets, changing selections, or editing text. The caret may scroll offscreen; its terminal cursor is hidden until visible again. Navigation (including a motion blocked at the document boundary), editing, or buffer navigation restores caret-following. Idle updates preserve a wheel-scrolled view, clamped to the document; resizing preserves unwrapped views and restores caret-following in ordinary wrapped buffers. Mouse-wheel up/down scrolls the document view three display rows per event without moving the primary or secondary carets, changing selections, or editing text. The caret may scroll offscreen; its terminal cursor is hidden until visible again. Navigation (including a motion blocked at the document boundary), editing, or buffer navigation restores caret-following. Idle updates preserve a wheel-scrolled view, clamped to the document; resizing preserves unwrapped views and restores caret-following in ordinary wrapped buffers.
@@ -198,6 +225,14 @@ Toggling preserves the source caret and anchors a visible caret to its previous
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. 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
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.
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.
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.
## Panes and splits ## Panes and splits
Splits can be nested in either direction. The new pane receives focus. Panes showing the same buffer share text, saved state, diagnostics, and undo/redo history, but retain independent carets, selections, modes, searches, and vertical/horizontal viewports. Buffer navigation changes only the focused pane. A file is still loaded once, even when displayed in several panes. Splits can be nested in either direction. The new pane receives focus. Panes showing the same buffer share text, saved state, diagnostics, and undo/redo history, but retain independent carets, selections, modes, searches, and vertical/horizontal viewports. Buffer navigation changes only the focused pane. A file is still loaded once, even when displayed in several panes.
@@ -259,7 +294,7 @@ Each buffer retains its shared text, path, saved state, and undo/redo history. V
| Command | Behavior | | Command | Behavior |
| --- | --- | | --- | --- |
| `:edit PATH` | Switch to an already-loaded file, or read and validate a new buffer; a nonexistent file opens empty | | `:edit PATH` | Switch to an already-loaded file, or read and validate a new buffer; a nonexistent file opens empty |
| `:buffers` | List stable IDs and paths; `%` marks active, `+` marks dirty | | `:buffers` | Open the searchable buffer picker; stable IDs and paths, `*` before the active buffer ID, `+` dirty |
| `:buffer N` | Switch to buffer ID `N` | | `:buffer N` | Switch to buffer ID `N` |
| `:buffer_next` / `:buffer_previous` | Switch in ascending-ID order, wrapping at either end | | `:buffer_next` / `:buffer_previous` | Switch in ascending-ID order, wrapping at either end |
| `:buffer_delete [N]` | Close the active or named buffer; refuse unsaved changes | | `:buffer_delete [N]` | Close the active or named buffer; refuse unsaved changes |
@@ -269,10 +304,18 @@ IDs start at 1 and are never renumbered or reused. Closing the last buffer creat
Switching from Insert commits its pending undo group and parks that view in Normal. Explicit buffer switching from Visual retains the pane's directed ranges and Visual mode for that buffer; switching back restores them. Command/search prompts, palette queries, and pending motion counts are transient, not restored per buffer. Buffer commands appear in the command palette and can be bound by their typed core actions in Normal mode. When several buffers are open, status shows the active ID and total count. Switching from Insert commits its pending undo group and parks that view in Normal. Explicit buffer switching from Visual retains the pane's directed ranges and Visual mode for that buffer; switching back restores them. Command/search prompts, palette queries, and pending motion counts are transient, not restored per buffer. Buffer commands appear in the command palette and can be bound by their typed core actions in Normal mode. When several buffers are open, status shows the active ID and total count.
**Space b l**, `:buffers`, or the **Buffers** command-palette action opens the picker. Type to filter IDs and paths with case-insensitive, whitespace-separated substring tokens; every token must match. Results retain ascending ID order. Ctrl+j/Ctrl+n/Down and Ctrl+k/Up select with wraparound, using the shared Selection bindings and overrides. Backspace edits the query; Ctrl+u clears it. Enter switches only the focused pane to the selected buffer without saving or discarding edits; no matches leave the picker open. Esc or Ctrl+p cancels without switching. Printable keys, including `:` and leader keys, belong to the query.
The picker uses the file explorer's centered floating-panel geometry, a cyan border, padded search input, and dimmed controls. Buffer IDs and status columns align across rows: IDs use the normal text foreground, a cyan `*` before the ID marks the active buffer, and a two-column gap separates the ID from the filename. Unsaved buffers show a yellow `+` in the first gap column; the selected row is blue. Paths under the home directory display as `~/…`, using the same component-aware abbreviation as the status bar; searching and file operations retain the original paths. Narrow or short terminals drop padding/borders as needed while keeping the query cursor and selected result visible whenever a result row fits.
The picker includes ordinary buffers from every tab and unnamed buffers as `[No Name]`; immutable diff snapshots are excluded. Labels sanitize path control characters, but selection uses stable buffer IDs, so displayed-name collisions still select the correct buffer. Switching out of a diff follows ordinary `:buffer N` behavior.
## File picker and explorer ## File picker and explorer
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.
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.
The floating explorer is centered, capped at 100 columns by 24 rows, and shrinks to fit smaller terminals. The tree mode appears in the title; the help row does not repeat it. The floating explorer is centered, capped at 100 columns by 24 rows, and shrinks to fit smaller terminals. The tree mode appears in the title; the help row does not repeat it.
@@ -311,13 +354,16 @@ The project root is the working directory captured at startup. Scanning uses the
Run `:diff` in Normal mode, choose **Before**, then **After**, and press Enter to compare. The command palette also exposes **Diff**; there is no default keybinding. Repository discovery starts at the active file's directory, or the startup project root for an unnamed buffer, and prefers jj over Git. Run `:diff` in Normal mode, choose **Before**, then **After**, and press Enter to compare. The command palette also exposes **Diff**; there is no default keybinding. Repository discovery starts at the active file's directory, or the startup project root for an unnamed buffer, and prefers jj over Git.
The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / Branches / Tags** for Git. Type to filter by revision/change ID, commit ID, reference name, or description. **Working checkout** stays first in every category, even when it does not match the query. As Before it is a disk snapshot, including eligible untracked files. As After it includes unsaved regular-text buffers and enables live editing; opening a comparison never saves them. The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / Branches / Tags** for Git. Both Before and After support case-insensitive fuzzy search by revision/change ID, commit ID, reference name, or description: `f1205pas` can find `sm/fin-1205-pilot-assignment-schema`. Space-separated tokens must all match, but can match different fields in any order; a single token cannot span unrelated fields. Exact, prefix, and contiguous matches rank ahead of scattered subsequences; otherwise equal matches favor names/IDs over descriptions, word boundaries, and tighter spans. Ties retain catalogue order, and an empty or whitespace-only query restores the original order. **Working checkout** stays first in every category, even when it does not match the query. As Before it is a disk snapshot, including eligible untracked files. As After it includes unsaved regular-text buffers and enables live editing; opening a comparison never saves them.
New comparisons default to **Branch changes**: Before becomes the unique merge base of the selected Before and After revisions, and After stays the selected tip. Changes exclusive to the Before branch are excluded. The Before border identifies both selected names and the immutable merge-base commit ID. **Snapshots** compares the two selected snapshots directly. **Ctrl+b** toggles the mode, also available as **`:diff.mode.toggle`**, the palette action, and remappable **`config.core.diff.mode.toggle`**. Toggling an opened comparison recomputes using the original selected endpoints. Selecting **Working checkout** on either side immediately forces **Snapshots**; Branch changes is unavailable while it is selected. Missing or multiple merge bases produce an explicit, recoverable selector error rather than silently falling back; choose Snapshots to compare such histories. jj's synthetic root is the empty ancestor of every jj history.
| Selector input | Effect | | Selector input | Effect |
| --- | --- | | --- | --- |
| Tab / Shift+Tab | Next / previous category | | Tab / Shift+Tab | Next / previous category |
| Up / Down, Ctrl+k / Ctrl+j | Previous / next entry | | Up / Down, Ctrl+k / Ctrl+j | Previous / next entry |
| Backspace / Ctrl+u | Remove a query grapheme / clear query | | Backspace / Ctrl+u | Remove a query grapheme / clear query |
| Ctrl+b | Toggle Branch changes / Snapshots (Working checkout requires Snapshots) |
| Enter | Choose the entry | | Enter | Choose the entry |
| Esc | Close either selector stage, cancel outstanding work, and restore the source buffer without discarding edits | | Esc | Close either selector stage, cancel outstanding work, and restore the source buffer without discarding edits |
@@ -327,19 +373,27 @@ The selector initially lists local revisions and known tracking refs **without c
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.
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 historical/remote After snapshots are read-only. Selecting **Working checkout** as 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. **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.
**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.
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.
Comparisons start in **split, vertical (left/right)** view by default. In Normal mode, **Space v** cycles split/unified; **Space s** switches a split between vertical (left/right) and horizontal (above/below, Before above After). Canonical commands are `:diff.view.toggle` and `:diff.orientation.toggle`; both are available in the palette and as remappable `config.core.diff.view.toggle` / `config.core.diff.orientation.toggle` actions. Configure new-comparison defaults with `config.diff_view_mode = "split"` or `"unified"` and `config.diff_split_orientation = "vertical"` or `"horizontal"`. Current choices survive file and tab switches; closing and reopening uses the configured defaults. Orientation stays unchanged while unified is active. Comparisons start in **split, vertical (left/right)** view by default. In Normal mode, **Space v** cycles split/unified; **Space s** switches a split between vertical (left/right) and horizontal (above/below, Before above After). Canonical commands are `:diff.view.toggle` and `:diff.orientation.toggle`; both are available in the palette and as remappable `config.core.diff.view.toggle` / `config.core.diff.orientation.toggle` actions. Configure new-comparison defaults with `config.diff_view_mode = "split"` or `"unified"` and `config.diff_split_orientation = "vertical"` or `"horizontal"`. Current choices survive file and tab switches; closing and reopening uses the configured defaults. Orientation stays unchanged while unified is active.
**Unified** uses one full-width text pane with both original line-number columns: context appears once, then each changed run shows removed (`-`) lines followed by added (`+`) lines, without alignment holes. Tab/Shift+Tab still switches logical Before/After focus, preserving native source coordinates, read-only protection, and Working After editing. The border shows the combined revisions and the focused side's editability. Word wrap, syntax/semantic and character highlighting, search, selections, excerpt expansion, and wheel scrolling work in either view. **Unified** uses one full-width text pane with both original line-number columns: context appears once, then each changed run shows removed (`-`) lines followed by added (`+`) lines, without alignment holes. Tab/Shift+Tab still switches logical Before/After focus, preserving native source coordinates, read-only protection, and writable After editing. The border shows the combined revisions and the focused side's editability. Word wrap, syntax/semantic and character highlighting, search, selections, excerpt expansion, and wheel scrolling work in either view.
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 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.
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.
**LSP definition, hover, and diagnostics work normally in focused Before and After text panes**, including historical snapshots. Each snapshot is served with its immutable revision text and its own document URI, not the current disk contents. Completion and completion edits are available only in writable Working-checkout After; they use the shared buffer's normal undo history. Revision selectors and the changed-file explorer are not document-action targets. **LSP definition, hover, and diagnostics work normally in focused Before and After text panes**, including historical snapshots. Each snapshot is served with its immutable revision text and its own document URI, not the current disk contents. Completion and completion edits are available only in writable After; they use the shared buffer's normal undo history. Revision selectors and the changed-file explorer are not document-action targets.
Accepted LSP definitions center the selection's caret row once in the destination pane, including wrapped visual rows and revealed diff excerpts. Centering uses the focused pane's actual height in either split orientation or unified view. Subsequent motions resume the configured `scroll_distance` policy; failed or superseded definitions preserve the current logical viewport.
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.
@@ -347,6 +401,10 @@ Use the ordinary **file-picker/file-explorer actions and bindings**: Space `o` o
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. Ordinary file exploration remains unannotated.
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.
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.
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.
@@ -365,7 +423,7 @@ Character-wise highlighting is enabled automatically within paired changed lines
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.
Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched. Missing counterparts use repeated muted `🯐🯑` diagonals (U+1FBD0/U+1FBD1), one glyph per column, on the normal terminal background with the same terminal `DarkGray` foreground as ordinary gutter text. Each pair joins the bottom-left corner to the top-right corner across two columns, giving a 45° angle when cells are twice as tall as they are wide. The pattern starts at each pane's left content edge, stays fixed during scrolling, and clips the final pair at odd widths. These Unicode 16 characters need a supporting terminal or font; Ghostty 1.3.1 draws them natively. Pixel-perfect joins and the exact angle depend on terminal cell geometry and rendering. Actual empty source lines retain their normal/change background and are not hatched.
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.
+6
View File
@@ -4,11 +4,17 @@
Vamp.configure do |config| Vamp.configure do |config|
config.leader = "space" config.leader = "space"
config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal
config.git_blame = true # false disables current-line inline blame
config.scroll_distance = 0 # nonnegative edge distance, or nil to center
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\n" => "↵" }
config.whitespace_scope = "all" # or "leading" for indentation only; newline markers remain independent
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.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 and live battery below; either bar accepts all segments.
+881
View File
@@ -0,0 +1,881 @@
//! Read-only file attribution; repository commands never touch the editor thread.
use std::{
collections::HashMap,
fs,
io::{self, Read, Write},
path::{Component, Path, PathBuf},
process::{Command, Stdio},
sync::{
Arc, Condvar, Mutex,
atomic::{AtomicBool, AtomicU64, Ordering},
},
thread::{self, JoinHandle},
time::{Duration, Instant},
};
const LIMIT: usize = 16 * 1024 * 1024;
const TIMEOUT: Duration = Duration::from_secs(5);
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
type ResultFile = Result<Option<Vec<Option<Arc<LineBlame>>>>, String>;
type Request = (u64, u64, PathBuf, String);
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct LineBlame {
pub(crate) commit: String,
pub(crate) author: String,
pub(crate) summary: String,
pub(crate) uncommitted: bool,
}
#[derive(Default)]
struct State {
request: Option<Request>,
result: Option<(u64, ResultFile)>,
}
#[derive(Default)]
struct Shared {
state: Mutex<State>,
wake: Condvar,
generation: AtomicU64,
stopped: AtomicBool,
}
pub(crate) struct BlameWorker {
shared: Arc<Shared>,
thread: Option<JoinHandle<()>>,
}
impl BlameWorker {
pub(crate) fn start() -> io::Result<Self> {
let shared = Arc::new(Shared::default());
let worker = Arc::clone(&shared);
let thread = thread::Builder::new()
.name("vamp-blame".into())
.spawn(move || {
loop {
let Ok(mut state) = worker.state.lock() else {
return;
};
while state.request.is_none() && !worker.stopped.load(Ordering::Acquire) {
let Ok(next) = worker.wake.wait(state) else {
return;
};
state = next;
}
if worker.stopped.load(Ordering::Acquire) {
break;
}
let (generation, id, path, contents) = state.request.take().unwrap();
drop(state);
let cancelled = || {
worker.stopped.load(Ordering::Acquire)
|| worker.generation.load(Ordering::Acquire) != generation
};
let result = read_file(&path, &contents, &cancelled);
let Ok(mut state) = worker.state.lock() else {
return;
};
if !cancelled() {
state.result = Some((id, result));
}
}
})?;
Ok(Self {
shared,
thread: Some(thread),
})
}
pub(crate) fn request(&mut self, id: u64, path: PathBuf, contents: String) -> io::Result<()> {
if contents.len() > LIMIT {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Blame input exceeds 16 MiB",
));
}
let mut state = self
.shared
.state
.lock()
.map_err(|_| io::Error::other("Blame worker stopped"))?;
let generation = self
.shared
.generation
.fetch_add(1, Ordering::AcqRel)
.wrapping_add(1);
state.request = Some((generation, id, path, contents));
state.result = None;
self.shared.wake.notify_one();
Ok(())
}
pub(crate) fn poll(&mut self) -> Option<(u64, ResultFile)> {
self.shared.state.lock().ok()?.result.take()
}
}
impl Drop for BlameWorker {
fn drop(&mut self) {
let state = self.shared.state.lock();
self.shared.stopped.store(true, Ordering::Release);
self.shared.wake.notify_one();
drop(state);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
struct Temporary(PathBuf);
impl Temporary {
fn new() -> Result<Self, String> {
for _ in 0..100 {
let path = std::env::temp_dir().join(format!(
"vamp-blame-{}-{}",
std::process::id(),
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
));
let mut builder = fs::DirBuilder::new();
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
match builder.create(&path) {
Ok(()) => return Ok(Self(path)),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(_) => return Err("Cannot create private blame directory".into()),
}
}
Err("Cannot create private blame directory".into())
}
}
impl Drop for Temporary {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
// Same process-group cancellation as repository comparisons, with a smaller output/time budget.
pub(crate) fn run(
command: Command,
input: &[u8],
cancelled: &dyn Fn() -> bool,
) -> Result<Option<Vec<u8>>, String> {
let (code, bytes) = run_status(command, input, cancelled)?;
Ok((code == Some(0)).then_some(bytes))
}
pub(crate) fn run_status(
mut command: Command,
input: &[u8],
cancelled: &dyn Fn() -> bool,
) -> Result<(Option<i32>, Vec<u8>), String> {
if cancelled() {
return Err("Blame cancelled".into());
}
for key in [
"GIT_DIR",
"GIT_COMMON_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
] {
command.env_remove(key);
}
command
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_NO_REPLACE_OBJECTS", "1")
.env("GIT_NO_LAZY_FETCH", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
#[cfg(not(unix))]
{
return Err("Blame requires Unix process-group cancellation".into());
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
let mut child = command.spawn().map_err(|_| "Cannot start blame command")?;
let stdout = child.stdout.take().unwrap();
let mut stdin = child.stdin.take().unwrap();
thread::scope(|scope| {
let output = scope.spawn(move || {
let mut bytes = Vec::new();
stdout
.take(LIMIT as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|_| "Cannot read blame output")?;
if bytes.len() > LIMIT {
return Err("Blame output exceeds 16 MiB");
}
Ok(bytes)
});
let writer = scope.spawn(move || stdin.write_all(input));
let started = Instant::now();
let mut status = None;
let failure = loop {
if cancelled() {
break Some("Blame cancelled");
}
if started.elapsed() >= TIMEOUT {
break Some("Blame command timed out");
}
if status.is_none() {
match child.try_wait() {
Ok(value) => status = value,
Err(_) => break Some("Cannot wait for blame command"),
}
}
if status.is_some() && output.is_finished() && writer.is_finished() {
break None;
}
thread::sleep(Duration::from_millis(10));
};
if failure.is_some() {
unsafe {
unsafe extern "C" {
fn kill(pid: i32, signal: i32) -> i32;
}
kill(-(child.id() as i32), 9);
}
let _ = child.kill();
let _ = child.wait();
}
let bytes = output.join().map_err(|_| "Blame reader stopped")?;
let written = writer.join().map_err(|_| "Blame writer stopped")?;
if let Some(error) = failure {
return Err(error.into());
}
let bytes = bytes.map_err(str::to_owned)?;
let status = status.unwrap();
if status.success() {
written.map_err(|_| "Cannot supply blame contents")?;
}
Ok((status.code(), bytes))
})
}
}
pub(crate) fn git(root: &Path) -> Command {
let mut command = git_without_literal_pathspecs(root);
command.arg("--literal-pathspecs");
command
}
pub(crate) fn git_without_literal_pathspecs(root: &Path) -> Command {
let mut command = Command::new("git");
command
.args([
"--no-pager",
"-c",
"core.hooksPath=/dev/null",
"-c",
"core.fsmonitor=false",
])
.arg("-C")
.arg(root);
command
}
fn oid(value: &str) -> bool {
matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn path_bytes(bytes: &[u8]) -> Result<PathBuf, String> {
#[cfg(unix)]
{
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
Ok(PathBuf::from(OsString::from_vec(bytes.to_vec())))
}
#[cfg(not(unix))]
{
Ok(PathBuf::from(
std::str::from_utf8(bytes).map_err(|_| "Invalid repository path")?,
))
}
}
fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> ResultFile {
if contents.len() > LIMIT {
return Err("Blame input exceeds 16 MiB".into());
}
// The editor has a final empty row after a newline; Git has no corresponding source line.
let rows = contents.split_inclusive('\n').count();
if rows == 0 {
return Ok(None);
}
if !fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_file()) {
return Ok(None);
}
let path = fs::canonicalize(path).map_err(|_| "Cannot resolve blame file")?;
let Some(root) = path.parent().and_then(|parent| {
parent
.ancestors()
.find(|root| root.join(".jj").exists() || root.join(".git").exists())
}) else {
return Ok(None);
};
let relative = path.strip_prefix(root).map_err(|_| "Invalid blame path")?;
if relative.components().any(|part| {
!matches!(part, Component::Normal(_))
|| part.as_os_str() == ".git"
|| part.as_os_str() == ".jj"
}) {
return Ok(None);
}
let mut temporary = None;
let mut command = git(root);
if root.join(".jj").exists() {
let mut jj = Command::new("jj");
jj.arg("--repository")
.arg(root)
.args([
"--ignore-working-copy",
"--at-operation=@",
"--no-pager",
"--color=never",
"log",
"--no-graph",
"-r",
"@-",
"-T",
"commit_id ++ \"\\n\"",
])
.current_dir(root);
let bytes = run(jj, &[], cancelled)?.ok_or("Cannot resolve Jujutsu blame parent")?;
let text = std::str::from_utf8(&bytes).map_err(|_| "Invalid Jujutsu blame metadata")?;
let mut parents = text.lines();
let Some(parent) = parents.next() else {
return Ok(None);
};
// A merge has no single committed baseline; never pick an arbitrary parent.
if parents.next().is_some() || parent.bytes().all(|byte| byte == b'0') {
return Ok(None);
}
if !oid(parent) {
return Err("Invalid Jujutsu blame metadata".into());
}
let mut repo = root.join(".jj/repo");
if repo.is_file() {
let pointer = fs::read(&repo).map_err(|_| "Cannot read Jujutsu repository pointer")?;
repo = root
.join(".jj")
.join(path_bytes(pointer.strip_suffix(b"\n").unwrap_or(&pointer))?);
}
let store = repo.join("store");
let target = fs::read(store.join("git_target"))
.map_err(|_| "Only Git-backed Jujutsu blame is supported")?;
let store = fs::canonicalize(
store.join(path_bytes(target.strip_suffix(b"\n").unwrap_or(&target))?),
)
.map_err(|_| "Cannot resolve Jujutsu Git store")?;
let private = Temporary::new()?;
fs::create_dir(private.0.join("refs"))
.map_err(|_| "Cannot prepare private blame repository")?;
#[cfg(unix)]
std::os::unix::fs::symlink(store.join("objects"), private.0.join("objects"))
.map_err(|_| "Cannot link blame objects")?;
#[cfg(not(unix))]
return Err("Jujutsu blame requires Unix object links".into());
fs::write(private.0.join("HEAD"), format!("{parent}\n"))
.map_err(|_| "Cannot prepare blame parent")?;
command
.arg("--git-dir")
.arg(&private.0)
.arg("--work-tree")
.arg(root);
temporary = Some(private);
}
let mut present = if let Some(private) = &temporary {
let mut command = git(root);
command.arg("--git-dir").arg(&private.0);
command
} else {
git(root)
};
let mut object = std::ffi::OsString::from("HEAD:");
object.push(relative);
present.args(["cat-file", "-e"]).arg(object);
if run(present, &[], cancelled)?.is_none() {
return Ok(None);
}
command
.args([
"blame",
"--incremental",
"--no-textconv",
"--contents",
"-",
"--",
])
.arg(relative);
let output = run(command, contents.as_bytes(), cancelled)?.ok_or("Cannot read Git blame")?;
parse(&output, rows).map(Some)
}
fn parse(bytes: &[u8], rows: usize) -> Result<Vec<Option<Arc<LineBlame>>>, String> {
let malformed = || "Invalid Git blame metadata".to_owned();
let text = std::str::from_utf8(bytes).map_err(|_| malformed())?;
let mut lines = text.lines();
let mut commits = HashMap::<&str, Arc<LineBlame>>::new();
let mut result = vec![None; rows];
while let Some(line) = lines.next() {
let mut header = line.split(' ');
let commit = header.next().filter(|id| oid(id)).ok_or_else(malformed)?;
let original = header.next().and_then(|n| n.parse::<usize>().ok());
let start = header.next().and_then(|n| n.parse::<usize>().ok());
let count = header.next().and_then(|n| n.parse::<usize>().ok());
let (Some(original), Some(start), Some(count)) = (original, start, count) else {
return Err(malformed());
};
if original == 0 || start == 0 || count == 0 || header.next().is_some() {
return Err(malformed());
}
original.checked_add(count - 1).ok_or_else(malformed)?;
let start = start - 1;
let end = start
.checked_add(count)
.filter(|&end| end <= rows)
.ok_or_else(malformed)?;
let mut author = None;
let mut summary = None;
let mut filename = false;
for line in lines.by_ref() {
if let Some(value) = line.strip_prefix("filename ") {
if value.is_empty() {
return Err(malformed());
}
filename = true;
break;
}
if line.is_empty() || line.starts_with('\t') || line.split(' ').next().is_some_and(oid)
{
return Err(malformed());
}
if let Some(value) = line.strip_prefix("author ") {
if author.replace(value).is_some() {
return Err(malformed());
}
}
if let Some(value) = line.strip_prefix("summary ") {
if summary.replace(value).is_some() {
return Err(malformed());
}
}
}
if !filename {
return Err(malformed());
}
let blame = if let Some(blame) = commits.get(commit) {
if author.is_some() || summary.is_some() {
return Err(malformed());
}
Arc::clone(blame)
} else {
let author = author.ok_or_else(malformed)?;
let summary = summary.ok_or_else(malformed)?;
let uncommitted = commit.bytes().all(|byte| byte == b'0');
let blame = Arc::new(LineBlame {
commit: if uncommitted {
String::new()
} else {
commit.into()
},
author: if uncommitted {
String::new()
} else {
author.into()
},
summary: if uncommitted {
String::new()
} else {
summary.into()
},
uncommitted,
});
commits.insert(commit, Arc::clone(&blame));
blame
};
for row in &mut result[start..end] {
if row.replace(Arc::clone(&blame)).is_some() {
return Err(malformed());
}
}
}
if result.iter().any(Option::is_none) {
return Err(malformed());
}
Ok(result)
}
#[cfg(all(test, unix))]
mod tests {
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 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"
);
}
}
+34 -24
View File
@@ -106,6 +106,7 @@ pub struct ChromeLine {
pub struct SegmentConfig { pub struct SegmentConfig {
pub title: String, pub title: String,
pub padding: usize, pub padding: usize,
pub margin: [usize; 2],
pub leading_edge: String, pub leading_edge: String,
pub trailing_edge: String, pub trailing_edge: String,
pub first_leading_edge: Option<String>, pub first_leading_edge: Option<String>,
@@ -116,6 +117,7 @@ impl Default for SegmentConfig {
Self { Self {
title: String::new(), title: String::new(),
padding: 1, padding: 1,
margin: [0; 2],
leading_edge: String::new(), leading_edge: String::new(),
trailing_edge: String::new(), trailing_edge: String::new(),
first_leading_edge: None, first_leading_edge: None,
@@ -146,7 +148,13 @@ 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| ((*name).into(), default_segment(name))) .map(|name| {
let mut segment = default_segment(name);
if *name == "position" {
segment.margin[0] = 1;
}
((*name).into(), segment)
})
.collect(), .collect(),
} }
} }
@@ -167,11 +175,17 @@ impl ChromeLine {
validate_name(name)?; validate_name(name)?;
} }
for (name, segment) in &self.segments { for (name, segment) in &self.segments {
if segment.padding > 64 { for (option, value) in [
("padding", segment.padding),
("leading_margin", segment.margin[0]),
("trailing_margin", segment.margin[1]),
] {
if value > 64 {
return Err(format!( return Err(format!(
"Chrome segment {name:?} padding must be between 0 and 64" "Chrome segment {name:?} {option} must be between 0 and 64"
)); ));
} }
}
for (option, text) in [ for (option, text) in [
("title", Some(&segment.title)), ("title", Some(&segment.title)),
("leading_edge", Some(&segment.leading_edge)), ("leading_edge", Some(&segment.leading_edge)),
@@ -258,6 +272,9 @@ struct LineOptions {
struct SegmentOptions { struct SegmentOptions {
title: Option<String>, title: Option<String>,
padding: Option<usize>, padding: Option<usize>,
margin: Option<usize>,
leading_margin: Option<usize>,
trailing_margin: Option<usize>,
leading_edge: Option<String>, leading_edge: Option<String>,
trailing_edge: Option<String>, trailing_edge: Option<String>,
first_leading_edge: Option<String>, first_leading_edge: Option<String>,
@@ -304,6 +321,15 @@ fn deserialize_line<'de, D: Deserializer<'de>>(
if let Some(padding) = options.padding { if let Some(padding) = options.padding {
segment.padding = padding; segment.padding = padding;
} }
if let Some(margin) = options.margin {
segment.margin = [margin; 2];
}
if let Some(margin) = options.leading_margin {
segment.margin[0] = margin;
}
if let Some(margin) = options.trailing_margin {
segment.margin[1] = margin;
}
if let Some(edge) = options.leading_edge { if let Some(edge) = options.leading_edge {
segment.leading_edge = edge; segment.leading_edge = edge;
} }
@@ -402,27 +428,6 @@ mod tests {
bottom: ChromeLine, bottom: ChromeLine,
} }
#[test]
fn bottom_keeps_metadata_on_the_right_without_filled_styles() {
let line = ChromeLine::bottom();
assert_eq!(
line.left,
[
"mode",
"pending",
"buffer",
"diagnostics",
"path",
"selections",
"count"
]
);
assert_eq!(line.right, ["language", "lsp", "position"]);
for name in ["language", "lsp"] {
assert_eq!(line.segment(name), &SegmentConfig::default());
}
}
#[test] #[test]
fn omitted_options_inherit_and_explicit_groups_replace_defaults() { fn omitted_options_inherit_and_explicit_groups_replace_defaults() {
let lines: Lines = serde_json::from_value(serde_json::json!({ let lines: Lines = serde_json::from_value(serde_json::json!({
@@ -521,6 +526,11 @@ mod tests {
serde_json::json!({"left": null}), serde_json::json!({"left": null}),
serde_json::json!({"tabs": {"padding": 65}}), serde_json::json!({"tabs": {"padding": 65}}),
serde_json::json!({"tabs": {"padding": null}}), 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": {"leading_edge": "\n"}}),
serde_json::json!({"tabs": {"title": "{unknown}"}}), serde_json::json!({"tabs": {"title": "{unknown}"}}),
serde_json::json!({"tabs": {"title": "{name"}}), serde_json::json!({"tabs": {"title": "{name"}}),
+187 -33
View File
@@ -15,20 +15,6 @@ function run() {
} }
"#; "#;
#[cfg(target_os = "macos")]
const MAC_WRITE: &str = r#"
ObjC.import('AppKit');
function run() {
var data = $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile;
var text = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding);
if (text.isNil()) throw Error('Clipboard input is not UTF-8');
var pasteboard = $.NSPasteboard.generalPasteboard;
pasteboard.clearContents;
if (!pasteboard.setStringForType(text, $.NSPasteboardTypeString))
throw Error('Cannot write plain-text clipboard');
}
"#;
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ClipboardMode { pub enum ClipboardMode {
System, System,
@@ -42,7 +28,7 @@ struct ClipboardCommand {
struct Commands { struct Commands {
reader: ClipboardCommand, reader: ClipboardCommand,
writer: ClipboardCommand, writer: Option<ClipboardCommand>,
} }
pub struct SystemClipboard { pub struct SystemClipboard {
@@ -72,10 +58,10 @@ impl SystemClipboard {
program: reader, program: reader,
args: &[], args: &[],
}, },
writer: ClipboardCommand { writer: Some(ClipboardCommand {
program: writer, program: writer,
args: &[], args: &[],
}, }),
})), })),
timeout: COMMAND_TIMEOUT, timeout: COMMAND_TIMEOUT,
} }
@@ -95,8 +81,13 @@ impl SystemClipboard {
} }
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)?.writer; match &commands.as_ref().map_err(Clone::clone)?.writer {
run(command, Some(text.as_bytes()), timeout).map(|_| ()) Some(command) => run(command, Some(text.as_bytes()), timeout).map(|_| ()),
#[cfg(target_os = "macos")]
None => macos::write(text),
#[cfg(not(target_os = "macos"))]
None => Err("System clipboard is unsupported on this platform".into()),
}
} }
} }
@@ -117,19 +108,13 @@ fn executable(name: &str) -> Option<PathBuf> {
fn select_commands() -> Result<Commands, String> { fn select_commands() -> Result<Commands, String> {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
if let Some(program) = executable("osascript") { Ok(Commands {
return Ok(Commands {
reader: ClipboardCommand { reader: ClipboardCommand {
program: program.clone(), program: executable("osascript").unwrap_or_else(|| "/usr/bin/osascript".into()),
args: &["-l", "JavaScript", "-e", MAC_READ], args: &["-l", "JavaScript", "-e", MAC_READ],
}, },
writer: ClipboardCommand { writer: None,
program, })
args: &["-l", "JavaScript", "-e", MAC_WRITE],
},
});
}
Err("System clipboard requires osascript with JXA/AppKit on PATH".into())
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
@@ -140,10 +125,10 @@ fn select_commands() -> Result<Commands, String> {
program: reader, program: reader,
args: &["--no-newline", "--type", "text"], args: &["--no-newline", "--type", "text"],
}, },
writer: ClipboardCommand { writer: Some(ClipboardCommand {
program: writer, program: writer,
args: &["--type", "text/plain;charset=utf-8"], args: &["--type", "text/plain;charset=utf-8"],
}, }),
}); });
} }
} }
@@ -154,10 +139,10 @@ fn select_commands() -> Result<Commands, String> {
program: program.clone(), program: program.clone(),
args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-out"], args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-out"],
}, },
writer: ClipboardCommand { writer: Some(ClipboardCommand {
program, program,
args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-in"], args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-in"],
}, }),
}); });
} }
} }
@@ -169,6 +154,173 @@ fn select_commands() -> Result<Commands, String> {
} }
} }
#[cfg(target_os = "macos")]
mod macos {
use std::ffi::{c_char, c_void};
use std::ptr;
type Object = *const c_void;
#[link(name = "CoreFoundation", kind = "framework")]
unsafe extern "C" {
fn CFStringCreateWithCString(
allocator: Object,
bytes: *const c_char,
encoding: u32,
) -> Object;
fn CFDataCreate(allocator: Object, bytes: *const u8, length: isize) -> Object;
fn CFRelease(object: Object);
}
#[link(name = "CoreServices", kind = "framework")]
unsafe extern "C" {
static kUTTypeUTF8PlainText: Object;
}
#[link(name = "ApplicationServices", kind = "framework")]
unsafe extern "C" {
fn PasteboardCreate(name: Object, pasteboard: *mut Object) -> i32;
fn PasteboardClear(pasteboard: Object) -> i32;
fn PasteboardPutItemFlavor(
pasteboard: Object,
item: usize,
flavor: Object,
data: Object,
flags: u32,
) -> i32;
}
struct Owned(Object);
impl Owned {
fn new(object: Object) -> Result<Self, String> {
if object.is_null() {
Err("Cannot allocate system clipboard data".into())
} else {
Ok(Self(object))
}
}
}
impl Drop for Owned {
fn drop(&mut self) {
// SAFETY: each handle owns one Core Foundation reference.
unsafe { CFRelease(self.0) };
}
}
fn check(status: i32) -> Result<(), String> {
if status == 0 {
Ok(())
} else {
Err(format!("System clipboard publication failed ({status})"))
}
}
fn write_to(name: Object, text: &str) -> Result<(), String> {
// SAFETY: framework constants and byte slices live throughout the calls.
unsafe {
let data = Owned::new(CFDataCreate(
ptr::null(),
text.as_ptr(),
text.len() as isize,
))?;
let mut board = ptr::null();
check(PasteboardCreate(name, &mut board))?;
let board = Owned::new(board)?;
// Prepare all allocations before changing clipboard contents.
check(PasteboardClear(board.0))?;
check(PasteboardPutItemFlavor(
board.0,
1,
kUTTypeUTF8PlainText,
data.0,
0,
))
}
}
pub(super) fn write(text: &str) -> Result<(), String> {
// SAFETY: the clipboard name is a process-lifetime, null-terminated literal.
let name = unsafe {
Owned::new(CFStringCreateWithCString(
ptr::null(),
c"com.apple.pasteboard.clipboard".as_ptr(),
0x0800_0100,
))?
};
write_to(name.0, text)
}
#[cfg(test)]
mod tests {
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 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());
}
}
}
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))] #[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn run(_: &ClipboardCommand, _: Option<&[u8]>, _: Duration) -> Result<Vec<u8>, String> { fn run(_: &ClipboardCommand, _: Option<&[u8]>, _: Duration) -> Result<Vec<u8>, String> {
Err("System clipboard is unsupported on this platform".into()) Err("System clipboard is unsupported on this platform".into())
@@ -514,6 +666,8 @@ mod tests {
.as_mut() .as_mut()
.unwrap() .unwrap()
.writer .writer
.as_mut()
.unwrap()
.program = fixture.command( .program = fixture.command(
"noisy", "noisy",
&format!("/bin/dd if=/dev/zero bs=65536 count=8 >&2\nexec /bin/cat > {state}"), &format!("/bin/dd if=/dev/zero bs=65536 count=8 >&2\nexec /bin/cat > {state}"),
+814 -37
View File
File diff suppressed because it is too large Load Diff
+1887 -105
View File
File diff suppressed because it is too large Load Diff
+404 -1
View File
@@ -1,3 +1,5 @@
use crate::diff::FileStatus;
use crate::repository::WorkingFileChange;
use ignore::WalkBuilder; use ignore::WalkBuilder;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
@@ -39,6 +41,8 @@ pub struct FileRow<'a> {
pub label: &'a str, pub label: &'a str,
/// Original virtual-catalog input index; absent for scanned entries and directories. /// Original virtual-catalog input index; absent for scanned entries and directories.
pub catalog_index: Option<usize>, pub catalog_index: Option<usize>,
pub status: Option<FileStatus>,
pub old_path: Option<&'a Path>,
pub is_dir: bool, pub is_dir: bool,
pub depth: usize, pub depth: usize,
pub expanded: bool, pub expanded: bool,
@@ -51,6 +55,9 @@ struct Node {
search_label: String, search_label: String,
catalog_index: Option<usize>, catalog_index: Option<usize>,
is_dir: bool, is_dir: bool,
status: Option<FileStatus>,
old_path: Option<PathBuf>,
virtual_entry: bool,
parent: Option<usize>, parent: Option<usize>,
children: Vec<usize>, children: Vec<usize>,
} }
@@ -86,12 +93,16 @@ pub struct FilePicker {
tree_for_node: Vec<usize>, tree_for_node: Vec<usize>,
tree_roots: Vec<usize>, tree_roots: Vec<usize>,
rows: Vec<VisibleRow>, rows: Vec<VisibleRow>,
viewport_revision: u64,
expanded: HashSet<usize>, expanded: HashSet<usize>,
filter_collapsed: HashSet<usize>, filter_collapsed: HashSet<usize>,
saved_selection: [Option<usize>; 2], saved_selection: [Option<usize>; 2],
filtered_selection: [Option<usize>; 2], filtered_selection: [Option<usize>; 2],
tree_searching: bool, tree_searching: bool,
warning: Option<String>, warning: Option<String>,
repository_changes: Vec<WorkingFileChange>,
has_repository_changes: bool,
has_virtual_entries: bool,
} }
fn label(path: &Path) -> String { fn label(path: &Path) -> String {
@@ -101,6 +112,28 @@ fn label(path: &Path) -> String {
.collect() .collect()
} }
fn safe_repository_path(path: &Path) -> bool {
!path.as_os_str().is_empty()
&& path.components().all(|component| {
matches!(component, std::path::Component::Normal(name) if name != ".git" && name != ".jj")
})
}
fn missing_repository_path(root: &Path, path: &Path) -> bool {
// A deleted leaf may be absent with all its parents, but never walk through symlinks.
let mut current = root.to_owned();
for component in path.components() {
current.push(component);
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.is_dir() => {}
Ok(_) => return false,
Err(error) if error.kind() == io::ErrorKind::NotFound => return true,
Err(_) => return false,
}
}
false
}
impl FilePicker { impl FilePicker {
pub fn scan(root: PathBuf, presentation: FilePresentation, view: FileView) -> io::Result<Self> { pub fn scan(root: PathBuf, presentation: FilePresentation, view: FileView) -> io::Result<Self> {
if !fs::metadata(&root)?.is_dir() { if !fs::metadata(&root)?.is_dir() {
@@ -156,6 +189,9 @@ impl FilePicker {
search_label: label.to_lowercase(), search_label: label.to_lowercase(),
label, label,
catalog_index: None, catalog_index: None,
status: None,
old_path: None,
virtual_entry: false,
is_dir: entry.file_type().unwrap().is_dir(), is_dir: entry.file_type().unwrap().is_dir(),
parent: None, parent: None,
children: Vec::new(), children: Vec::new(),
@@ -186,6 +222,9 @@ impl FilePicker {
search_label: label.to_lowercase(), search_label: label.to_lowercase(),
label, label,
catalog_index, catalog_index,
status: None,
old_path: None,
virtual_entry: false,
is_dir, is_dir,
parent: None, parent: None,
children: Vec::new(), children: Vec::new(),
@@ -255,16 +294,20 @@ impl FilePicker {
focused: true, focused: true,
error: warning.clone(), error: warning.clone(),
tree_for_node: vec![0; nodes.len()], tree_for_node: vec![0; nodes.len()],
has_virtual_entries: nodes.iter().any(|node| node.virtual_entry),
nodes, nodes,
tree: Vec::new(), tree: Vec::new(),
tree_roots: Vec::new(), tree_roots: Vec::new(),
rows: Vec::new(), rows: Vec::new(),
viewport_revision: 0,
expanded: HashSet::new(), expanded: HashSet::new(),
filter_collapsed: HashSet::new(), filter_collapsed: HashSet::new(),
saved_selection: [None, None], saved_selection: [None, None],
filtered_selection: [None, None], filtered_selection: [None, None],
tree_searching: false, tree_searching: false,
warning, warning,
repository_changes: Vec::new(),
has_repository_changes: false,
}; };
for root in roots { for root in roots {
let tree = picker.compact(root, None); let tree = picker.compact(root, None);
@@ -305,6 +348,10 @@ impl FilePicker {
tree tree
} }
pub(crate) fn viewport_revision(&self) -> u64 {
self.viewport_revision
}
pub fn entries(&self) -> impl ExactSizeIterator<Item = FileRow<'_>> + '_ { pub fn entries(&self) -> impl ExactSizeIterator<Item = FileRow<'_>> + '_ {
self.rows.iter().map(|row| { self.rows.iter().map(|row| {
let node = &self.nodes[row.node]; let node = &self.nodes[row.node];
@@ -315,6 +362,8 @@ impl FilePicker {
.map_or(node.label.as_str(), |tree| self.tree[tree].label.as_str()), .map_or(node.label.as_str(), |tree| self.tree[tree].label.as_str()),
is_dir: node.is_dir, is_dir: node.is_dir,
catalog_index: node.catalog_index, catalog_index: node.catalog_index,
status: node.status,
old_path: node.old_path.as_deref(),
depth: row.depth, depth: row.depth,
expanded: row.expanded, expanded: row.expanded,
} }
@@ -499,6 +548,8 @@ impl FilePicker {
} }
fn project(&mut self, selected: Option<usize>, select_match: bool) { fn project(&mut self, selected: Option<usize>, select_match: bool) {
static NEXT_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
self.viewport_revision = NEXT_REVISION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let query = self.query.to_lowercase(); let query = self.query.to_lowercase();
let tokens: Vec<_> = query.split_whitespace().collect(); let tokens: Vec<_> = query.split_whitespace().collect();
let matches = |node: &Node| tokens.iter().all(|token| node.search_label.contains(token)); let matches = |node: &Node| tokens.iter().all(|token| node.search_label.contains(token));
@@ -721,11 +772,158 @@ impl FilePicker {
pub(crate) fn refresh(&mut self) { pub(crate) fn refresh(&mut self) {
match Self::scan(self.root.clone(), self.presentation, self.view) { match Self::scan(self.root.clone(), self.presentation, self.view) {
Ok(replacement) => self.replace_catalog(replacement), Ok(mut replacement) => {
replacement.set_repository_changes(self.repository_changes.clone());
self.replace_catalog(replacement);
}
Err(error) => self.error = Some(format!("Refresh failed: {error}")), Err(error) => self.error = Some(format!("Refresh failed: {error}")),
} }
} }
pub(crate) fn has_repository_changes(&self) -> bool {
self.has_repository_changes
}
pub(crate) fn set_repository_changes(&mut self, changes: Vec<WorkingFileChange>) -> bool {
if !self.has_virtual_entries && changes == self.repository_changes {
return false;
}
if self.nodes.iter().any(|node| {
node.virtual_entry
&& !node.is_dir
&& fs::symlink_metadata(self.root.join(&node.path))
.is_ok_and(|metadata| metadata.is_file())
}) {
// Reuse the scanner's ignore/symlink rules when a deleted file returns.
match Self::scan(self.root.clone(), self.presentation, self.view) {
Ok(mut replacement) => {
replacement.set_repository_changes(changes);
replacement.error = self.error.clone().or(replacement.error);
self.replace_catalog(replacement);
return true;
}
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
}
}
let changes: Vec<_> = changes
.into_iter()
.filter(|change| safe_repository_path(&change.path))
.map(|mut change| {
change.old_path = change.old_path.filter(|path| safe_repository_path(path));
change
})
.collect();
let by_path: HashMap<_, _> = changes
.iter()
.map(|change| (change.path.as_path(), change))
.collect();
let scanned: HashSet<_> = self
.nodes
.iter()
.filter(|node| !node.virtual_entry)
.map(|node| (node.path.as_path(), node.is_dir))
.collect();
let mut virtual_files = HashSet::new();
let mut virtual_dirs = HashSet::new();
for change in &changes {
if change.status != FileStatus::Removed
|| scanned.contains(&(change.path.as_path(), false))
|| !missing_repository_path(&self.root, &change.path)
{
continue;
}
virtual_files.insert(change.path.clone());
for parent in change.path.ancestors().skip(1) {
if parent.as_os_str().is_empty() {
break;
}
if !scanned.contains(&(parent, true)) && !virtual_dirs.contains(parent) {
virtual_dirs.insert(parent.to_owned());
}
}
}
let topology_changed = self.nodes.iter().filter(|node| node.virtual_entry).count()
!= virtual_files.len() + virtual_dirs.len()
|| self
.nodes
.iter()
.filter(|node| node.virtual_entry)
.any(|node| {
!(if node.is_dir {
&virtual_dirs
} else {
&virtual_files
})
.contains(node.path.as_path())
});
let mut metadata_changed = false;
for node in &mut self.nodes {
let change = (!node.is_dir && node.catalog_index.is_none())
.then(|| by_path.get(node.path.as_path()))
.flatten();
let status = change.map(|change| change.status);
let old_path = change.and_then(|change| change.old_path.as_ref());
metadata_changed |= node.status != status || node.old_path.as_ref() != old_path;
node.status = status;
if node.old_path.as_ref() != old_path {
node.old_path = old_path.cloned();
}
}
if topology_changed {
let mut nodes: Vec<_> = self
.nodes
.iter()
.filter(|node| !node.virtual_entry)
.map(|node| Node {
path: node.path.clone(),
label: node.label.clone(),
search_label: node.search_label.clone(),
catalog_index: node.catalog_index,
status: node.status,
old_path: node.old_path.clone(),
virtual_entry: false,
is_dir: node.is_dir,
parent: None,
children: Vec::new(),
})
.collect();
nodes.extend(
virtual_files
.into_iter()
.map(|path| (path, false))
.chain(virtual_dirs.into_iter().map(|path| (path, true)))
.map(|(path, is_dir)| {
let label = label(&path);
let change = (!is_dir).then(|| by_path.get(path.as_path())).flatten();
Node {
status: change.map(|change| change.status),
old_path: change.and_then(|change| change.old_path.clone()),
path,
search_label: label.to_lowercase(),
label,
catalog_index: None,
virtual_entry: true,
is_dir,
parent: None,
children: Vec::new(),
}
}),
);
let mut replacement = Self::from_nodes(
self.root.clone(),
nodes,
self.presentation,
self.view,
self.warning.clone(),
);
replacement.error = self.error.clone();
self.replace_catalog(replacement);
}
self.has_repository_changes = self.nodes.iter().any(|node| node.status.is_some());
self.repository_changes = changes;
topology_changed || metadata_changed
}
pub(crate) fn replace_paths(&mut self, paths: impl IntoIterator<Item = (usize, PathBuf)>) { pub(crate) fn replace_paths(&mut self, paths: impl IntoIterator<Item = (usize, PathBuf)>) {
self.replace_catalog(Self::from_paths(self.root.clone(), paths)); self.replace_catalog(Self::from_paths(self.root.clone(), paths));
} }
@@ -835,6 +1033,7 @@ mod tests {
assert!(!rows.is_empty()); assert!(!rows.is_empty());
for row in rows { for row in rows {
assert_eq!(row.catalog_index, expected_index(&row)); assert_eq!(row.catalog_index, expected_index(&row));
assert!(row.status.is_none() && row.old_path.is_none());
} }
} }
} }
@@ -881,9 +1080,213 @@ mod tests {
picker.set_view(view); picker.set_view(view);
picker.reveal_path(Path::new("nested/file.txt")); picker.reveal_path(Path::new("nested/file.txt"));
assert!(picker.entries().all(|row| row.catalog_index.is_none())); 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);
assert!(picker.has_repository_changes());
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!(!picker.has_repository_changes());
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] #[test]
fn nested_ignore_negation_hidden_files_and_vcs_metadata() { fn nested_ignore_negation_hidden_files_and_vcs_metadata() {
let directory = Directory::new(); let directory = Directory::new();
+885
View File
@@ -0,0 +1,885 @@
//! Read-only source-line changes against Git HEAD or a single Jujutsu @- parent.
use crate::{
blame::{git, git_without_literal_pathspecs, run, run_status},
diff::line_hunks,
editor::normalize_newlines,
repository::{
WorkingFileChange, WorkingFileReader, jj_command, jj_file_pattern, safe_path, store_for_jj,
},
};
use serde::Deserialize;
use std::{
fs, io,
path::{Path, PathBuf},
process::Command,
sync::{
Arc, Condvar, Mutex,
atomic::{AtomicBool, AtomicU64, Ordering},
},
thread::{self, JoinHandle},
};
const FILE_LIMIT: usize = 16 * 1024 * 1024;
const BATCH_LIMIT: usize = 64 * 1024 * 1024;
const FILE_COUNT_LIMIT: usize = 1024;
type Files = Vec<(usize, PathBuf, Arc<str>)>;
type FileResult = Result<Vec<Option<Change>>, String>;
pub(crate) type ResultBatch = Vec<(usize, FileResult)>;
type PickerRequest = (PathBuf, Vec<(PathBuf, Arc<str>)>);
type Request = (u64, u64, Files, Option<PickerRequest>);
pub(crate) struct PickerChanges {
pub(crate) root: PathBuf,
pub(crate) result: Result<Vec<WorkingFileChange>, String>,
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GutterStyle {
#[default]
Bar,
Symbols,
LineNumbers,
None,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub(crate) enum Change {
Added,
Removed,
Modified,
}
#[derive(Default)]
struct State {
request: Option<Request>,
result: Option<(u64, ResultBatch, Option<PickerChanges>)>,
}
#[derive(Default)]
struct Shared {
state: Mutex<State>,
wake: Condvar,
generation: AtomicU64,
stopped: AtomicBool,
}
pub(crate) struct Worker {
shared: Arc<Shared>,
thread: Option<JoinHandle<()>>,
}
impl Worker {
pub(crate) fn start() -> io::Result<Self> {
let shared = Arc::new(Shared::default());
let worker = Arc::clone(&shared);
let thread = thread::Builder::new()
.name("vamp-gutter".into())
.spawn(move || {
let mut picker_reader = WorkingFileReader::default();
loop {
let Ok(mut state) = worker.state.lock() else {
return;
};
while state.request.is_none() && !worker.stopped.load(Ordering::Acquire) {
let Ok(next) = worker.wake.wait(state) else {
return;
};
state = next;
}
if worker.stopped.load(Ordering::Acquire) {
return;
}
let (generation, id, files, picker) = state.request.take().unwrap();
drop(state);
let cancelled = || {
worker.stopped.load(Ordering::Acquire)
|| worker.generation.load(Ordering::Acquire) != generation
};
let mut results = Vec::with_capacity(files.len());
for (buffer, path, contents) in files {
if cancelled() {
break;
}
results.push((buffer, read_file(&path, &contents, &cancelled)));
}
let picker = picker.map(|(root, buffers)| PickerChanges {
result: picker_reader.read(&root, &buffers, &cancelled),
root,
});
let Ok(mut state) = worker.state.lock() else {
return;
};
if !cancelled() {
state.result = Some((id, results, picker));
}
}
})?;
Ok(Self {
shared,
thread: Some(thread),
})
}
pub(crate) fn request(
&mut self,
id: u64,
files: Files,
picker: Option<PickerRequest>,
) -> io::Result<()> {
let size = files.iter().try_fold(0usize, |size, (_, path, text)| {
size.checked_add(text.len())?
.checked_add(path.as_os_str().len())
});
let picker_size = picker.as_ref().map_or(Some(0), |(root, buffers)| {
buffers
.iter()
.try_fold(root.as_os_str().len(), |size, (path, text)| {
size.checked_add(text.len())?
.checked_add(path.as_os_str().len())
})
});
let count = files.len() + picker.as_ref().map_or(0, |(_, buffers)| buffers.len());
if count > FILE_COUNT_LIMIT
|| size
.zip(picker_size)
.and_then(|(files, picker)| files.checked_add(picker))
.is_none_or(|size| size > BATCH_LIMIT)
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Gutter batch exceeds 64 MiB or 1024 files",
));
}
let mut state = self
.shared
.state
.lock()
.map_err(|_| io::Error::other("Gutter worker stopped"))?;
let generation = self
.shared
.generation
.fetch_add(1, Ordering::AcqRel)
.wrapping_add(1);
state.request = Some((generation, id, files, picker));
state.result = None;
self.shared.wake.notify_one();
Ok(())
}
pub(crate) fn poll(&mut self) -> Option<(u64, ResultBatch, Option<PickerChanges>)> {
self.shared.state.lock().ok()?.result.take()
}
}
impl Drop for Worker {
fn drop(&mut self) {
let state = self.shared.state.lock();
self.shared.stopped.store(true, Ordering::Release);
self.shared.wake.notify_one();
drop(state);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
fn checked(command: Command, cancelled: &dyn Fn() -> bool) -> Result<Vec<u8>, String> {
run(command, &[], cancelled)?.ok_or_else(|| "Cannot read gutter repository metadata".into())
}
fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> FileResult {
if contents.len() > FILE_LIMIT {
return Err("Gutter file exceeds 16 MiB".into());
}
if contents.as_bytes().contains(&0) {
return Ok(vec![]);
}
match fs::symlink_metadata(path) {
Ok(metadata) if !metadata.file_type().is_file() => return Ok(vec![]),
Ok(_) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error.to_string()),
}
// Check the lexical path before canonicalizing: metadata files and symlinked parents
// must not acquire the markers of their target outside the checkout.
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| e.to_string())?
.join(path)
};
if absolute
.components()
.any(|part| part.as_os_str() == ".git" || part.as_os_str() == ".jj")
{
return Ok(vec![]);
}
let Some(root) = absolute.parent().and_then(|parent| {
parent
.ancestors()
.find(|root| root.join(".jj").exists() || root.join(".git").exists())
}) else {
return Ok(vec![]);
};
if absolute
.ancestors()
.take_while(|part| *part != root)
.any(|part| {
fs::symlink_metadata(part).is_ok_and(|metadata| metadata.file_type().is_symlink())
})
{
return Ok(vec![]);
}
let relative = absolute.strip_prefix(root).map_err(|e| e.to_string())?;
if safe_path(relative).is_err() {
return Ok(vec![]);
}
// Resolve ancestors above the checkout, preserving a missing named-buffer tail.
let root = fs::canonicalize(root).map_err(|e| e.to_string())?;
let baseline = if root.join(".jj").exists() {
jj_baseline(&root, relative, cancelled)?
} else {
git_baseline(&root, relative, cancelled)?
};
let Some(baseline) = baseline else {
return Ok(vec![]);
};
if baseline.contains(&0) {
return Ok(vec![]);
}
let Ok(baseline) = std::str::from_utf8(&baseline) else {
return Ok(vec![]);
};
if cancelled() {
return Err("Gutter cancelled".into());
}
Ok(changes(baseline, contents))
}
fn eligible(
root: &Path,
relative: &Path,
store: Option<&Path>,
cancelled: &dyn Fn() -> bool,
) -> Result<bool, String> {
let command = |literal_pathspecs| {
let mut command = if literal_pathspecs {
git(root)
} else {
git_without_literal_pathspecs(root)
};
if let Some(store) = store {
command
.arg("--git-dir")
.arg(store)
.arg("--work-tree")
.arg(root);
}
command
};
let mut tracked = command(true);
tracked
.args(["ls-files", "--cached", "-z", "--"])
.arg(relative);
let bytes = checked(tracked, cancelled)?;
if bytes
.split(|byte| *byte == 0)
.any(|entry| entry == relative.as_os_str().as_encoded_bytes())
{
return Ok(true);
}
let mut ignored = command(false);
ignored
.args(["check-ignore", "--no-index", "-q", "--"])
.arg(relative);
match run_status(ignored, &[], cancelled)?.0 {
Some(0) => Ok(false),
Some(1) => Ok(true),
_ => Err("Cannot read gutter ignore rules".into()),
}
}
fn git_baseline(
root: &Path,
relative: &Path,
cancelled: &dyn Fn() -> bool,
) -> Result<Option<Vec<u8>>, String> {
let mut head = git(root);
head.args(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
let (code, head) = run_status(head, &[], cancelled)?;
if code == Some(1) {
// An unborn repository still has an index and ignore rules.
return Ok(eligible(root, relative, None, cancelled)?.then(Vec::new));
}
if code != Some(0) {
return Err("Cannot resolve Git gutter HEAD".into());
}
let head = std::str::from_utf8(&head)
.map_err(|_| "Invalid Git gutter HEAD")?
.trim();
let mut tree = git(root);
tree.args(["ls-tree", "-z", "--full-tree", head, "--"])
.arg(relative);
let entries = checked(tree, cancelled)?;
let Some(entry) = entries
.split(|byte| *byte == 0)
.find(|entry| !entry.is_empty())
else {
return Ok(eligible(root, relative, None, cancelled)?.then(Vec::new));
};
let tab = entry
.iter()
.position(|byte| *byte == b'\t')
.ok_or("Invalid Git gutter tree")?;
if &entry[tab + 1..] != relative.as_os_str().as_encoded_bytes() {
return Err("Unexpected Git gutter path".into());
}
let fields = std::str::from_utf8(&entry[..tab]).map_err(|_| "Invalid Git gutter tree")?;
let mut fields = fields.split(' ');
if !matches!(fields.next(), Some("100644" | "100755")) || fields.next() != Some("blob") {
return Ok(None);
}
let object = fields.next().ok_or("Missing Git gutter blob")?;
let mut cat = git(root);
cat.args(["cat-file", "blob", object]);
checked(cat, cancelled).map(Some)
}
fn jj_file_kind(
root: &Path,
revision: &str,
pattern: &str,
cancelled: &dyn Fn() -> bool,
) -> Result<Option<String>, String> {
let mut command = jj_command(root);
command.args([
"file",
"list",
"-r",
revision,
"-T",
"json(file_type) ++ \"\\n\"",
"--",
pattern,
]);
let bytes = checked(command, cancelled)?;
if bytes.is_empty() {
return Ok(None);
}
let kind =
serde_json::from_slice(&bytes).map_err(|_| "Invalid Jujutsu gutter file metadata")?;
Ok(Some(kind))
}
fn jj_baseline(
root: &Path,
relative: &Path,
cancelled: &dyn Fn() -> bool,
) -> Result<Option<Vec<u8>>, String> {
let Ok(store) = store_for_jj(root) else {
return Ok(None);
};
let Some(path) = relative.to_str() else {
return Ok(None);
};
let mut parents = jj_command(root);
parents.args([
"log",
"--no-graph",
"-r",
"@-",
"-T",
"commit_id ++ \"\\n\"",
]);
let bytes = checked(parents, cancelled)?;
let ids = std::str::from_utf8(&bytes).map_err(|_| "Invalid Jujutsu gutter parents")?;
let mut ids = ids.lines();
let Some(parent) = ids.next() else {
return Ok(None);
};
// ponytail: no merged-parent materializer exists here; hide markers on merges
// rather than report changes against an arbitrary first parent.
if ids.next().is_some() {
return Ok(None);
}
let pattern = jj_file_pattern(path);
let baseline_kind = if parent.bytes().all(|byte| byte == b'0') {
None
} else {
jj_file_kind(root, parent, &pattern, cancelled)?
};
if let Some(kind) = baseline_kind {
if !matches!(kind.as_str(), "file" | "conflict") {
return Ok(None);
}
let mut show = jj_command(root);
show.args(["file", "show", "-r", parent, "-T", "", "--", &pattern]);
return checked(show, cancelled).map(Some);
}
if let Some(kind) = jj_file_kind(root, "@", &pattern, cancelled)? {
return Ok(matches!(kind.as_str(), "file" | "conflict").then(Vec::new));
}
Ok(eligible(root, relative, Some(&store), cancelled)?.then(Vec::new))
}
fn changes(before: &str, after: &str) -> Vec<Option<Change>> {
fn source(text: &str) -> (Vec<String>, bool) {
(
text.split_terminator('\n').map(str::to_owned).collect(),
text.ends_with('\n'),
)
}
let before = normalize_newlines(before);
let after = normalize_newlines(after);
if before == after {
return vec![None; after.split('\n').count()];
}
let (before, before_eof) = source(&before);
let (after_lines, after_eof) = source(&after);
let mut markers = vec![None; after.split('\n').count()];
for hunk in line_hunks(&before, before_eof, &after_lines, after_eof) {
if hunk.after_len == 0 {
let anchor = hunk.after_start.min(markers.len() - 1);
// A deletion sharing a row with a replacement remains modified.
if markers[anchor].is_none() {
markers[anchor] = Some(Change::Removed);
}
} else {
for offset in 0..hunk.after_len {
markers[hunk.after_start + offset] = Some(if offset < hunk.before_len {
Change::Modified
} else {
Change::Added
});
}
}
}
markers
}
#[cfg(all(test, unix))]
mod tests {
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();
}
}
}
+2
View File
@@ -1,9 +1,11 @@
mod battery; mod battery;
mod blame;
pub mod chrome; pub mod chrome;
pub mod clipboard; pub mod clipboard;
pub mod diff; pub mod diff;
pub mod editor; pub mod editor;
pub mod files; pub mod files;
pub mod gutter;
pub mod lsp; pub mod lsp;
mod lsp_config; mod lsp_config;
mod markdown; mod markdown;
+9 -2
View File
@@ -12,7 +12,7 @@ Usage: vamp [--config PATH | --no-config] [FILE]
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
Normal/Visual: Space is leader; Space Space opens palette; Esc cancels prefix Normal/Visual: Space is leader; Space Space opens palette; Esc cancels prefix
Normal leader: Space w saves; Space b n/p/l/d next/previous/list/close buffer Normal leader: Space w saves; Space b n/p/l/d next/previous/pick/close buffer
Files: Space o floating tree INSERT; Space e docked tree NORMAL (right by default) Files: Space o floating tree INSERT; Space e docked tree NORMAL (right by default)
tree NORMAL: hjkl navigate; INSERT: type to filter, Ctrl+hjkl navigate; Enter toggles/opens tree NORMAL: hjkl navigate; INSERT: type to filter, Ctrl+hjkl navigate; Enter toggles/opens
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
@@ -23,17 +23,22 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
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
Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported) Ctrl+o older jump location; Ctrl+i/Tab newer (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 u undo, U redo, : command, / search, n/N next/previous match
dd deletes lines; cc changes lines and enters Insert; counts: 3dd, 2cc; Esc cancels prefix 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
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
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/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
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
v/V switches selection shape; same selector or Esc cancels retaining carets v/V switches selection shape; same selector or Esc cancels retaining carets
@@ -54,6 +59,7 @@ Save actions: core.save, core.force_save, core.save_and_quit, core.force_save_an
Reload preserves/clamps views and clears stale undo/redo; snapshots never reload Reload preserves/clamps views and clears stale undo/redo; snapshots never reload
Dirty buffers keep edits; missing/unreadable/invalid changed files keep text and warn Dirty buffers keep edits; missing/unreadable/invalid changed files keep text and warn
Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffer_delete [N], :buffer_force_delete [N] Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffer_delete [N], :buffer_force_delete [N]
:buffers / Space b l searches IDs/paths; Enter switches, Esc/Ctrl+p cancels, Ctrl+u clears
Aliases: bnext, bprev, bdelete, bdelete!; arguments and safety rules are unchanged Aliases: bnext, bprev, bdelete, bdelete!; arguments and safety rules are unchanged
:quit refuses any dirty buffer; both save-and-quit commands require all buffers clean :quit refuses any dirty buffer; both save-and-quit commands require all buffers clean
Restart: :restart closes clean buffers; unsaved buffers remain open and block restart with a warning Restart: :restart closes clean buffers; unsaved buffers remain open and block restart with a warning
@@ -71,6 +77,7 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
Normal [/] explicitly expands context; Insert brackets remain literal text Normal [/] explicitly expands context; Insert brackets remain literal text
Normal Space v cycles split/unified; Space s flips left/right vs above/below split Normal Space v cycles split/unified; Space s flips left/right vs above/below split
Defaults: config.diff_view_mode = \"split\"; config.diff_split_orientation = \"vertical\" Defaults: config.diff_view_mode = \"split\"; config.diff_split_orientation = \"vertical\"
Working After follows descendant checkouts; config.diff_follow_working_copy = false disables it
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
+1546 -53
View File
File diff suppressed because it is too large Load Diff
+410 -9
View File
@@ -38,14 +38,21 @@ pub struct Config {
pub file_picker_view: String, pub file_picker_view: String,
pub file_explorer_view: String, pub file_explorer_view: String,
pub clipboard_mode: String, pub clipboard_mode: String,
pub git_blame: bool,
pub git_gutter: crate::gutter::GutterStyle,
pub top_bar: ChromeLine, pub top_bar: ChromeLine,
pub bottom_bar: ChromeLine, pub bottom_bar: ChromeLine,
pub popup: PopupConfig, pub popup: PopupConfig,
pub labels: BTreeMap<String, String>, pub labels: BTreeMap<String, String>,
pub scroll_distance: Option<usize>,
pub highlight_trailing_whitespace: bool,
pub whitespace_characters: BTreeMap<char, char>,
pub whitespace_scope: crate::editor::WhitespaceScope,
pub diff_context_lines: usize, pub diff_context_lines: usize,
pub diff_expand_lines: usize, pub diff_expand_lines: usize,
pub diff_view_mode: crate::diff::ViewMode, pub diff_view_mode: crate::diff::ViewMode,
pub diff_split_orientation: crate::panes::SplitAxis, pub diff_split_orientation: crate::panes::SplitAxis,
pub diff_follow_working_copy: bool,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -80,14 +87,21 @@ impl Default for Config {
file_picker_view: "tree".into(), file_picker_view: "tree".into(),
file_explorer_view: "tree".into(), file_explorer_view: "tree".into(),
clipboard_mode: "system".into(), clipboard_mode: "system".into(),
git_blame: true,
git_gutter: crate::gutter::GutterStyle::default(),
top_bar: ChromeLine::top(), top_bar: ChromeLine::top(),
bottom_bar: ChromeLine::bottom(), bottom_bar: ChromeLine::bottom(),
popup: PopupConfig::default(), popup: PopupConfig::default(),
labels: BTreeMap::new(), labels: BTreeMap::new(),
scroll_distance: Some(0),
highlight_trailing_whitespace: false,
whitespace_characters: BTreeMap::new(),
whitespace_scope: crate::editor::WhitespaceScope::All,
diff_context_lines: 10, diff_context_lines: 10,
diff_expand_lines: 15, diff_expand_lines: 15,
diff_view_mode: crate::diff::ViewMode::Split, diff_view_mode: crate::diff::ViewMode::Split,
diff_split_orientation: crate::panes::SplitAxis::Vertical, diff_split_orientation: crate::panes::SplitAxis::Vertical,
diff_follow_working_copy: true,
} }
} }
} }
@@ -109,14 +123,21 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
file_picker_view, file_picker_view,
file_explorer_view, file_explorer_view,
clipboard_mode, clipboard_mode,
git_blame,
git_gutter,
top_bar, top_bar,
bottom_bar, bottom_bar,
popup, popup,
labels, labels,
scroll_distance,
highlight_trailing_whitespace,
whitespace_characters,
whitespace_scope,
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_follow_working_copy,
.. ..
} = *response; } = *response;
let mut config = Config::default(); let mut config = Config::default();
@@ -139,14 +160,21 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.file_picker_view = file_picker_view; config.file_picker_view = file_picker_view;
config.file_explorer_view = file_explorer_view; config.file_explorer_view = file_explorer_view;
config.clipboard_mode = clipboard_mode; config.clipboard_mode = clipboard_mode;
config.git_blame = git_blame;
config.git_gutter = git_gutter;
config.top_bar = top_bar; config.top_bar = top_bar;
config.bottom_bar = bottom_bar; config.bottom_bar = bottom_bar;
config.popup = popup; config.popup = popup;
config.labels = labels; config.labels = labels;
config.scroll_distance = scroll_distance;
config.highlight_trailing_whitespace = highlight_trailing_whitespace;
config.whitespace_characters = whitespace_characters;
config.whitespace_scope = whitespace_scope;
config.diff_context_lines = diff_context_lines; config.diff_context_lines = diff_context_lines;
config.diff_expand_lines = diff_expand_lines; config.diff_expand_lines = diff_expand_lines;
config.diff_view_mode = diff_view_mode; config.diff_view_mode = diff_view_mode;
config.diff_split_orientation = diff_split_orientation; config.diff_split_orientation = diff_split_orientation;
config.diff_follow_working_copy = diff_follow_working_copy;
Ok(config) Ok(config)
} }
Response::Error { Response::Error {
@@ -438,6 +466,17 @@ fn diff_expand_lines<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Resul
diff_line_count(deserializer, "diff_expand_lines", 1) diff_line_count(deserializer, "diff_expand_lines", 1)
} }
fn scroll_distance<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<usize>, D::Error> {
Option::<usize>::deserialize(deserializer).map_err(|error| {
serde::de::Error::custom(format!(
"scroll_distance must be nil or an Integer between 0 and {}: {error}",
usize::MAX
))
})
}
// Startup-only configuration does not inflate every queued plugin response. // Startup-only configuration does not inflate every queued plugin response.
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -453,6 +492,8 @@ struct ConfigFrame {
file_picker_view: String, file_picker_view: String,
file_explorer_view: String, file_explorer_view: String,
clipboard_mode: String, clipboard_mode: String,
git_blame: bool,
git_gutter: crate::gutter::GutterStyle,
#[serde(deserialize_with = "crate::chrome::top")] #[serde(deserialize_with = "crate::chrome::top")]
top_bar: ChromeLine, top_bar: ChromeLine,
#[serde(deserialize_with = "crate::chrome::bottom")] #[serde(deserialize_with = "crate::chrome::bottom")]
@@ -460,12 +501,43 @@ struct ConfigFrame {
popup: PopupConfig, popup: PopupConfig,
#[serde(deserialize_with = "crate::chrome::labels")] #[serde(deserialize_with = "crate::chrome::labels")]
labels: BTreeMap<String, String>, labels: BTreeMap<String, String>,
#[serde(deserialize_with = "scroll_distance")]
scroll_distance: Option<usize>,
highlight_trailing_whitespace: bool,
#[serde(deserialize_with = "whitespace_characters")]
whitespace_characters: BTreeMap<char, char>,
whitespace_scope: crate::editor::WhitespaceScope,
#[serde(deserialize_with = "diff_context_lines")] #[serde(deserialize_with = "diff_context_lines")]
diff_context_lines: usize, diff_context_lines: usize,
#[serde(deserialize_with = "diff_expand_lines")] #[serde(deserialize_with = "diff_expand_lines")]
diff_expand_lines: usize, diff_expand_lines: usize,
diff_view_mode: crate::diff::ViewMode, diff_view_mode: crate::diff::ViewMode,
diff_split_orientation: crate::panes::SplitAxis, diff_split_orientation: crate::panes::SplitAxis,
diff_follow_working_copy: bool,
}
fn whitespace_characters<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<BTreeMap<char, char>, D::Error> {
use unicode_width::UnicodeWidthChar;
let characters = BTreeMap::<char, char>::deserialize(deserializer)?;
for (&whitespace, &marker) in &characters {
if !whitespace.is_whitespace()
|| (!matches!(whitespace, '\t' | '\n')
&& (whitespace.is_control() || whitespace.width().unwrap_or(0) == 0))
{
return Err(serde::de::Error::custom(
"whitespace_characters keys must be visible-width whitespace characters, tabs, or newlines",
));
}
if marker.is_control() || marker.width() != Some(1) {
return Err(serde::de::Error::custom(
"whitespace_characters values must be printable single-cell characters",
));
}
}
Ok(characters)
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -854,6 +926,8 @@ fn read_frame(reader: &mut impl BufRead) -> Result<Vec<u8>, String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::{editor::Input, session::EditorSession};
use super::*; use super::*;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
@@ -980,7 +1054,6 @@ end
config.bind "E", config.core.edit config.bind "E", config.core.edit
config.bind "B", config.core.buffer config.bind "B", config.core.buffer
config.bind "P", config.core.plugins config.bind "P", config.core.plugins
config.bind "L", config.core.buffers
config.bind "N", config.core.buffer_next config.bind "N", config.core.buffer_next
config.bind "V", config.core.buffer_previous config.bind "V", config.core.buffer_previous
config.bind "D", config.core.buffer_delete config.bind "D", config.core.buffer_delete
@@ -1057,9 +1130,6 @@ end"#,
assert_eq!(session.editor().lines(), &["base"]); assert_eq!(session.editor().lines(), &["base"]);
session.handle(Input::Char('P')); session.handle(Input::Char('P'));
assert!(session.editor().message().contains("text")); assert!(session.editor().message().contains("text"));
session.handle(Input::Char('L'));
assert!(session.editor().message().contains("buffer.txt"));
assert!(session.editor().message().contains("second.txt"));
session.handle(Input::Char('N')); session.handle(Input::Char('N'));
assert_eq!(session.editor().buffer_id(), 2); assert_eq!(session.editor().buffer_id(), 2);
session.handle(Input::Char('V')); session.handle(Input::Char('V'));
@@ -1204,14 +1274,267 @@ end
assert!(load_config(&path).unwrap_err().contains(error), "{value}"); assert!(load_config(&path).unwrap_err().contains(error), "{value}");
} }
for frame in [ for frame in [
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical"}"#, r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical"}"#, r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical"}"#, r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
] { ] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}"); assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
} }
} }
#[test]
fn git_gutter_config_modes_default_and_rejection() {
use crate::gutter::GutterStyle;
let fixture = Fixture::new();
let path = fixture.file("config.rb", "Vamp.configure { |_config| }");
assert_eq!(load_config(&path).unwrap().git_gutter, GutterStyle::Bar);
for (name, expected) in [
("bar", GutterStyle::Bar),
("symbols", GutterStyle::Symbols),
("line_numbers", GutterStyle::LineNumbers),
("none", GutterStyle::None),
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.git_gutter = {name:?} }}"),
);
assert_eq!(load_config(&path).unwrap().git_gutter, expected);
}
for value in ["\"unsupported\"", "\"\"", "false", "7", ":bar"] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.git_gutter = {value} }}"),
);
assert!(load_config(&path).is_err(), "{value}");
}
}
#[test]
fn git_blame_boolean_config_typed_toggle_palette_and_wire_validation() {
let fixture = Fixture::new();
let config = fixture.file("config.rb", "Vamp.configure { |_config| }");
assert!(load_config(&config).unwrap().git_blame);
let config = fixture.file("config.rb", r#"
Vamp.configure do |config|
config.git_blame = false
config.bind "G", config.core.git_blame.toggle
raise "mutable blame action" unless config.core.git_blame.frozen? && config.core.git_blame.toggle.frozen?
end
"#);
let mut session = EditorSession::open(None, vec![], Some(&config)).unwrap();
assert!(!session.editor().git_blame());
session.handle(Input::Char('G'));
assert!(session.editor().git_blame());
session.execute("git_blame.toggle extra");
assert!(session.editor().git_blame());
session.execute("git_blame.toggle");
assert!(!session.editor().git_blame());
session.handle(Input::Ctrl('p'));
for ch in "git blame".chars() {
session.handle(Input::Char(ch));
}
assert_eq!(session.editor().palette().unwrap().entries.len(), 1);
session.handle(Input::Enter);
assert!(session.editor().git_blame());
for value in ["nil", "0", r#""false""#, "[]", "{}"] {
let config = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.git_blame = {value} }}"),
);
assert!(
load_config(&config)
.unwrap_err()
.contains("git_blame must be true or false")
);
}
let mut frame = serde_json::json!({
"type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [],
"leader": "space", "file_explorer_side": "right", "file_picker_view": "tree",
"file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true,
"git_gutter": "bar",
"scroll_distance": 0,
"highlight_trailing_whitespace": false,
"whitespace_characters": {},
"whitespace_scope": "all",
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
"diff_follow_working_copy": true
});
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
for value in [
serde_json::Value::Null,
serde_json::json!(0),
serde_json::json!("false"),
] {
frame["git_blame"] = value;
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
}
frame.as_object_mut().unwrap().remove("git_blame");
assert!(serde_json::from_value::<Response>(frame).is_err());
}
#[test]
fn trailing_whitespace_configuration_rejects_non_booleans() {
let fixture = Fixture::new();
for value in ["nil", "0", "\"true\"", "[]", "{}"] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |c| c.highlight_trailing_whitespace = {value} }}"),
);
assert!(
load_config(&path)
.unwrap_err()
.contains("highlight_trailing_whitespace must be true or false")
);
}
}
#[test]
fn whitespace_characters_validate_real_ruby_configuration() {
let fixture = Fixture::new();
for value in [
"nil",
"false",
"[]",
r#"{ space: "·" }"#,
r#"{ " " => 1 }"#,
r#"{ "" => "." }"#,
r#"{ " " => "." }"#,
r#"{ " " => ".." }"#,
r#"{ "x" => "." }"#,
r#"{ "\u000b" => "." }"#,
r#"{ "\r" => "." }"#,
r#"{ "\u000c" => "." }"#,
r#"{ "\u0085" => "." }"#,
r#"{ "\u200b" => "." }"#,
r#"{ " " => "\t" }"#,
r#"{ " " => "\n" }"#,
r#"{ " " => "\u0301" }"#,
r#"{ " " => "界" }"#,
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |c| c.whitespace_characters = {value} }}"),
);
assert!(load_config(&path).is_err(), "{value}");
}
let path = fixture.file(
"config.rb",
r#"Vamp.configure { |c| c.whitespace_characters = { " " => "·", "\t" => "→", "\n" => "↵", "\u00a0" => "⍽", "\u3000" => "_" } }"#,
);
assert_eq!(
load_config(&path).unwrap().whitespace_characters,
BTreeMap::from([
(' ', '·'),
('\t', '→'),
('\n', '↵'),
('\u{a0}', '⍽'),
('\u{3000}', '_'),
]),
);
}
#[test]
fn whitespace_scope_validates_ruby_atomically_and_requires_valid_wire_values() {
use crate::editor::WhitespaceScope;
let fixture = Fixture::new();
let path = fixture.file("config.rb", "Vamp.configure {}");
assert_eq!(
load_config(&path).unwrap().whitespace_scope,
WhitespaceScope::All
);
for (value, expected) in [
("all", WhitespaceScope::All),
("leading", WhitespaceScope::Leading),
] {
let path = fixture.file(
"config.rb",
&format!(r#"Vamp.configure {{ |c| c.whitespace_scope = "{value}" }}"#),
);
assert_eq!(load_config(&path).unwrap().whitespace_scope, expected);
}
for value in [
"nil",
"false",
"0",
"[]",
"{}",
":all",
r#""""#,
r#""unknown""#,
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |c| c.whitespace_scope = {value} }}"),
);
assert!(load_config(&path).is_err(), "{value}");
let path = fixture.file(
"config.rb",
&format!(
r#"Vamp.configure do |c|
c.whitespace_scope = "leading"
begin
c.whitespace_scope = {value}
raise "invalid scope accepted"
rescue TypeError, ArgumentError
end
end"#,
),
);
assert_eq!(
load_config(&path).unwrap().whitespace_scope,
WhitespaceScope::Leading
);
}
let mut frame = serde_json::json!({
"type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [],
"leader": "space", "file_explorer_side": "right", "file_picker_view": "tree",
"file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true,
"git_gutter": "bar", "scroll_distance": 0, "highlight_trailing_whitespace": false,
"whitespace_characters": {}, "whitespace_scope": "all",
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
"diff_follow_working_copy": true
});
for value in ["all", "leading"] {
frame["whitespace_scope"] = serde_json::json!(value);
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
}
for value in [
serde_json::Value::Null,
serde_json::json!(false),
serde_json::json!(0),
serde_json::json!([]),
serde_json::json!({}),
serde_json::json!(""),
serde_json::json!("unknown"),
] {
frame["whitespace_scope"] = value;
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
}
frame.as_object_mut().unwrap().remove("whitespace_scope");
assert!(serde_json::from_value::<Response>(frame).is_err());
}
#[test]
fn working_diff_tracking_rejects_non_boolean_configuration() {
let fixture = Fixture::new();
for value in ["nil", "0", r#""false""#, "[]", "{}"] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.diff_follow_working_copy = {value} }}"),
);
assert!(
load_config(&path)
.unwrap_err()
.contains("diff_follow_working_copy must be true or false")
);
}
}
#[test] #[test]
fn chrome_configuration_normalizes_ruby_values_and_retains_unspecified_defaults() { fn chrome_configuration_normalizes_ruby_values_and_retains_unspecified_defaults() {
let fixture = Fixture::new(); let fixture = Fixture::new();
@@ -1363,6 +1686,11 @@ end
("bar.top", r#"{mode: {leading_edge: "\n"}}"#, "single-line"), ("bar.top", r#"{mode: {leading_edge: "\n"}}"#, "single-line"),
("bar.top", "{language: {padding: 65}}", "between 0 and 64"), ("bar.top", "{language: {padding: 65}}", "between 0 and 64"),
("bar.bottom", "{tabs: {padding: '1'}}", "must be an Integer"), ("bar.bottom", "{tabs: {padding: '1'}}", "must be an Integer"),
("bar.top", "{lsp: {margin: 65}}", "between 0 and 64"),
("bar.bottom", "{lsp: {margin: -1}}", "between 0 and 64"),
("bar.top", "{lsp: {margin: '1'}}", "must be an Integer"),
("bar.bottom", "{lsp: {margin: 1.5}}", "must be an Integer"),
("bar.top", "{lsp: {margin: nil}}", "must be an Integer"),
("bar.top", "{mode: {title: nil}}", "must be a String"), ("bar.top", "{mode: {title: nil}}", "must be a String"),
( (
"bar.bottom", "bar.bottom",
@@ -1423,9 +1751,14 @@ end
"bindings": [], "leader": "space", "file_explorer_side": "right", "bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree", "file_picker_view": "tree", "file_explorer_view": "tree",
"top_bar": {}, "bottom_bar": {}, "labels": {}, "top_bar": {}, "bottom_bar": {}, "labels": {},
"scroll_distance": 0,
"highlight_trailing_whitespace": false,
"whitespace_characters": {},
"whitespace_scope": "all",
"popup": {"command": {}, "search": {}}, "popup": {"command": {}, "search": {}},
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7, "clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7,
"diff_view_mode": "split", "diff_split_orientation": "vertical" "diff_view_mode": "split", "diff_split_orientation": "vertical",
"diff_follow_working_copy": true
}); });
for setting in ["diff_context_lines", "diff_expand_lines"] { for setting in ["diff_context_lines", "diff_expand_lines"] {
for value in [ for value in [
@@ -1462,6 +1795,67 @@ end
} }
} }
#[test]
fn config_scroll_distance_accepts_nil_and_counts_and_rejects_invalid_values() {
let fixture = Fixture::new();
let frame = serde_json::json!({
"version": 1, "type": "config", "plugins": [], "lsp": {},
"bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree",
"clipboard_mode": "system", "git_blame": true, "git_gutter": "bar",
"highlight_trailing_whitespace": false,
"whitespace_characters": {},
"whitespace_scope": "all",
"top_bar": {}, "bottom_bar": {}, "labels": {},
"popup": {"command": {}, "search": {}},
"diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
"diff_follow_working_copy": true, "scroll_distance": 0
});
for distance in [None, Some(0), Some(7), Some(usize::MAX)] {
let value = distance.map_or_else(|| "nil".into(), |value| value.to_string());
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.scroll_distance = {value} }}"),
);
assert_eq!(load_config(&path).unwrap().scroll_distance, distance);
let mut valid_frame = frame.clone();
valid_frame["scroll_distance"] = serde_json::json!(distance);
let response = serde_json::from_value::<Response>(valid_frame).unwrap();
let Response::Config(config) = response else {
panic!("expected a configuration frame");
};
assert_eq!(config.scroll_distance, distance);
}
for value in [
"-1",
"1.5",
"\"3\"",
"true",
"false",
"18446744073709551616",
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.scroll_distance = {value} }}"),
);
let error = load_config(&path).unwrap_err();
assert!(
error.contains("scroll_distance") && error.contains("must be"),
"{value}: {error}"
);
let mut invalid_frame = frame.clone();
invalid_frame["scroll_distance"] = serde_json::from_str(value).unwrap();
let error = serde_json::from_value::<Response>(invalid_frame)
.err()
.unwrap();
assert!(
error.to_string().contains("scroll_distance"),
"{value}: {error}"
);
}
}
#[test] #[test]
fn lsp_configuration_is_disabled_unless_explicitly_enabled() { fn lsp_configuration_is_disabled_unless_explicitly_enabled() {
let fixture = Fixture::new(); let fixture = Fixture::new();
@@ -1837,10 +2231,17 @@ end"#,
"version": 1, "type": "config", "plugins": [], "lsp": {}, "version": 1, "type": "config", "plugins": [], "lsp": {},
"bindings": [], "leader": "space", "file_explorer_side": "right", "bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree", "file_picker_view": "tree", "file_explorer_view": "tree",
"git_blame": true,
"git_gutter": "bar",
"scroll_distance": 0,
"highlight_trailing_whitespace": false,
"whitespace_characters": {},
"whitespace_scope": "all",
"top_bar": {}, "bottom_bar": {}, "labels": {}, "top_bar": {}, "bottom_bar": {}, "labels": {},
"popup": {"command": {}, "search": {}}, "popup": {"command": {}, "search": {}},
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15, "clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical" "diff_view_mode": "split", "diff_split_orientation": "vertical",
"diff_follow_working_copy": true
}); });
let Response::Config(config) = serde_json::from_value::<Response>(frame.clone()).unwrap() let Response::Config(config) = serde_json::from_value::<Response>(frame.clone()).unwrap()
else { else {
+77 -3
View File
@@ -35,9 +35,9 @@ module Vamp
chrome_name!(name) chrome_name!(name)
style = chrome_hash!(value).to_h do |option, item| style = chrome_hash!(value).to_h do |option, item|
case option case option
when "padding" when "padding", "margin", "leading_margin", "trailing_margin"
unless item.is_a?(Integer) && item.between?(0, 64) unless item.is_a?(Integer) && item.between?(0, 64)
raise ArgumentError, "padding must be an Integer between 0 and 64" raise ArgumentError, "#{option} must be an Integer between 0 and 64"
end end
when "title", "leading_edge", "trailing_edge", "first_leading_edge" when "title", "leading_edge", "trailing_edge", "first_leading_edge"
item = string!(item, option) item = string!(item, option)
@@ -136,13 +136,20 @@ module Vamp
@file_picker_view = "tree" @file_picker_view = "tree"
@file_explorer_view = "tree" @file_explorer_view = "tree"
@clipboard_mode = "system" @clipboard_mode = "system"
@git_blame = true
@git_gutter = "bar"
@bar = BarConfig.new @bar = BarConfig.new
@popup = PopupConfig.new @popup = PopupConfig.new
@labels = {} @labels = {}
@scroll_distance = 0
@highlight_trailing_whitespace = false
@whitespace_characters = {}
@whitespace_scope = "all"
@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
end end
# @param path [String] relative to the configuration file # @param path [String] relative to the configuration file
@@ -187,6 +194,22 @@ module Vamp
@clipboard_mode = nonempty_string!(mode, "clipboard mode") @clipboard_mode = nonempty_string!(mode, "clipboard mode")
end end
# @param enabled [Boolean]
# @return [Boolean]
def git_blame=(enabled)
raise TypeError, "git_blame must be true or false" unless [true, false].include?(enabled)
@git_blame = enabled
end
# @param style [String] "bar", "symbols", "line_numbers", or "none"
def git_gutter=(style)
style = nonempty_string!(style, "git gutter")
raise ArgumentError, "git gutter must be bar, symbols, line_numbers, or none" unless %w[bar symbols line_numbers none].include?(style)
@git_gutter = style
end
# @param values [Hash{String, Symbol => String}] # @param values [Hash{String, Symbol => String}]
# @return [Hash] # @return [Hash]
def labels=(values) def labels=(values)
@@ -195,6 +218,42 @@ module Vamp
end end
end end
# @param distance [Integer, nil] nonnegative cursor margin, or nil to center
# @return [Integer, nil]
def scroll_distance=(distance)
@scroll_distance = distance.nil? ? nil : diff_line_count!(distance, "scroll_distance", 0)
end
# @param enabled [Boolean]
# @return [Boolean]
def highlight_trailing_whitespace=(enabled)
raise TypeError, "highlight_trailing_whitespace must be true or false" unless [true, false].include?(enabled)
@highlight_trailing_whitespace = enabled
end
# @param characters [Hash{String => String}] whitespace character to single-cell marker
# @return [Hash{String => String}]
def whitespace_characters=(characters)
raise TypeError, "whitespace_characters must be a Hash" unless characters.is_a?(Hash)
characters.each do |whitespace, marker|
unless [whitespace, marker].all? { |value| value.is_a?(String) && value.length == 1 }
raise TypeError, "whitespace_characters keys and values must be single-character Strings"
end
end
@whitespace_characters = characters.dup
end
# @param scope [String] "all" or "leading"
# @return [String]
def whitespace_scope=(scope)
scope = Vamp.string!(scope, "whitespace_scope")
raise ArgumentError, "whitespace_scope must be all or leading" unless %w[all leading].include?(scope)
@whitespace_scope = scope
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)
@@ -225,6 +284,14 @@ module Vamp
@diff_split_orientation = orientation @diff_split_orientation = orientation
end end
# @param enabled [Boolean]
# @return [Boolean]
def diff_follow_working_copy=(enabled)
raise TypeError, "diff_follow_working_copy must be true or false" unless [true, false].include?(enabled)
@diff_follow_working_copy = enabled
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
@@ -256,10 +323,17 @@ module Vamp
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings, { "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings,
"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_gutter" => @git_gutter,
"scroll_distance" => @scroll_distance,
"highlight_trailing_whitespace" => @highlight_trailing_whitespace,
"whitespace_characters" => @whitespace_characters,
"whitespace_scope" => @whitespace_scope,
"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 }
end end
private private
+769 -11
View File
@@ -12,8 +12,9 @@ use std::{
}; };
use crate::{ use crate::{
blame::BlameWorker,
diff::{Focus, Operation as DiffOperation, Outcome as DiffOutcome}, diff::{Focus, Operation as DiffOperation, Outcome as DiffOutcome},
editor::{Cursor, Editor, Effect, Input, Invocation, Mode}, editor::{BlameSource, Cursor, Editor, Effect, Input, Invocation, Mode},
lsp::Lsp, lsp::Lsp,
plugin::{ plugin::{
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate, DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
@@ -78,10 +79,18 @@ impl DiffWorker {
DiffOperation::Compare { DiffOperation::Compare {
before, before,
after, after,
mode,
buffers, buffers,
} => repository } => repository
.compare(id, &before, &after, &buffers) .compare(id, &before, &after, mode, &buffers)
.map(DiffOutcome::Comparison), .map(DiffOutcome::Comparison),
DiffOperation::RefreshCheckout {
comparison_id,
follow_working,
buffers,
} => repository
.refresh_checkout(comparison_id, id, follow_working, &buffers)
.map(DiffOutcome::CheckoutRefresh),
DiffOperation::LoadFile { DiffOperation::LoadFile {
comparison_id, comparison_id,
file_index, file_index,
@@ -156,6 +165,15 @@ struct AsyncRequests {
latest: Option<(u64, InvocationSource)>, latest: Option<(u64, InvocationSource)>,
} }
struct GutterSource {
buffer: usize,
revision: u64,
save_revision: u64,
path: PathBuf,
dirty: bool,
contents: Arc<str>,
}
/// Owns command eligibility and execution hosts together. Frontends can inspect /// Owns command eligibility and execution hosts together. Frontends can inspect
/// the editor, but cannot independently mutate its plugin registry. /// the editor, but cannot independently mutate its plugin registry.
pub struct EditorSession { pub struct EditorSession {
@@ -172,9 +190,21 @@ pub struct EditorSession {
pending_completion: bool, pending_completion: bool,
hover_owner: Option<String>, hover_owner: Option<String>,
diff_workers: BTreeMap<usize, DiffWorker>, diff_workers: BTreeMap<usize, DiffWorker>,
blame_worker: Option<BlameWorker>,
blame_source: Option<BlameSource>,
blame_lines: Option<Vec<Option<Arc<crate::blame::LineBlame>>>>,
blame_request_id: u64,
gutter_worker: Option<crate::gutter::Worker>,
gutter_sources: Vec<GutterSource>,
gutter_request_id: u64,
gutter_pending: bool,
last_gutter_refresh: Option<Instant>,
picker_source: Option<(usize, PathBuf)>,
battery: Option<crate::battery::Battery>, battery: Option<crate::battery::Battery>,
battery_enabled: bool, battery_enabled: bool,
last_file_refresh: Instant, last_file_refresh: Instant,
last_diff_refresh: Instant,
pub(crate) diff_follow_working_copy: bool,
} }
impl EditorSession { impl EditorSession {
@@ -224,6 +254,12 @@ impl EditorSession {
config.clipboard_mode config.clipboard_mode
)); ));
} }
editor.set_git_blame(config.git_blame);
editor.set_gutter_style(config.git_gutter);
editor.set_scroll_distance(config.scroll_distance);
editor.set_highlight_trailing_whitespace(config.highlight_trailing_whitespace);
editor.set_whitespace_characters(config.whitespace_characters);
editor.set_whitespace_scope(config.whitespace_scope);
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines); editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation); editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
if let Err(error) = if let Err(error) =
@@ -273,12 +309,25 @@ impl EditorSession {
pending_completion: false, pending_completion: false,
hover_owner: None, hover_owner: None,
diff_workers: BTreeMap::new(), diff_workers: BTreeMap::new(),
blame_worker: None,
blame_source: None,
blame_lines: None,
blame_request_id: 0,
gutter_worker: None,
gutter_sources: Vec::new(),
gutter_request_id: 0,
gutter_pending: false,
last_gutter_refresh: None,
picker_source: None,
battery: None, battery: None,
battery_enabled: false, battery_enabled: false,
last_file_refresh: Instant::now(), last_file_refresh: Instant::now(),
last_diff_refresh: Instant::now(),
diff_follow_working_copy: config.diff_follow_working_copy,
}; };
session.synchronize_documents(); session.synchronize_documents();
session.poll_battery(); session.poll_battery();
session.poll_gutter_at(Instant::now());
Ok(session) Ok(session)
} }
@@ -314,6 +363,14 @@ impl EditorSession {
} }
pub fn handle(&mut self, input: Input) { pub fn handle(&mut self, input: Input) {
if input == Input::Ctrl('r')
&& self
.editor
.normal_file_picker()
.is_some_and(|picker| picker.focused)
{
self.last_gutter_refresh = None;
}
self.cancel_pending_popup_request(); self.cancel_pending_popup_request();
let invocation = self.editor.handle(input); let invocation = self.editor.handle(input);
self.synchronize_documents(); self.synchronize_documents();
@@ -341,6 +398,7 @@ impl EditorSession {
return false; return false;
}; };
if !diff.loading if !diff.loading
&& !diff.refresh_pending()
&& let Some(id) = worker.pending.take() && let Some(id) = worker.pending.take()
{ {
worker.cancelled_request.store(id, Ordering::Release); worker.cancelled_request.store(id, Ordering::Release);
@@ -348,18 +406,16 @@ impl EditorSession {
} }
true true
}); });
let Some((id, operation)) = self.editor.take_diff_request() else { while let Some((tab, id, operation)) = self.editor.take_next_diff_request() {
return; if let std::collections::btree_map::Entry::Vacant(entry) = self.diff_workers.entry(tab)
}; {
let tab = self.editor.tab_id(); match DiffWorker::start(self.editor.diff_root(tab)) {
if let std::collections::btree_map::Entry::Vacant(entry) = self.diff_workers.entry(tab) {
match DiffWorker::start(self.editor.diff_root()) {
Ok(worker) => { Ok(worker) => {
entry.insert(worker); entry.insert(worker);
} }
Err(error) => { Err(error) => {
self.editor.apply_diff_result(id, Err(error)); self.editor.apply_diff_result(id, Err(error));
return; continue;
} }
} }
} }
@@ -373,6 +429,7 @@ impl EditorSession {
self.diff_workers.remove(&tab); self.diff_workers.remove(&tab);
} }
} }
}
fn poll_diff(&mut self) -> bool { fn poll_diff(&mut self) -> bool {
self.start_diff_request(); self.start_diff_request();
@@ -511,6 +568,7 @@ impl EditorSession {
} }
fn synchronize_documents(&mut self) -> bool { fn synchronize_documents(&mut self) -> bool {
let blame_changed = self.poll_blame();
// Leaving a request's source invalidates it permanently, even if history returns there. // Leaving a request's source invalidates it permanently, even if history returns there.
if let Some(requests) = self.requests.get_mut("core") if let Some(requests) = self.requests.get_mut("core")
&& requests.latest.is_some_and(|(_, source)| { && requests.latest.is_some_and(|(_, source)| {
@@ -535,7 +593,7 @@ impl EditorSession {
} }
if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) { if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) {
self.documents.clear(); self.documents.clear();
return self.refresh_lsp_names(); return self.refresh_lsp_names() | blame_changed;
} }
let mut events = Vec::new(); let mut events = Vec::new();
if !self.editor.should_quit() { if !self.editor.should_quit() {
@@ -611,7 +669,7 @@ impl EditorSession {
}); });
} }
let mut failures = Vec::new(); let mut failures = Vec::new();
let mut changed = false; let mut changed = blame_changed;
for event in &events { for event in &events {
if let Err(error) = self.lsp.notify_document(event) { if let Err(error) = self.lsp.notify_document(event) {
self.editor.set_message(format!("LSP: {error}")); self.editor.set_message(format!("LSP: {error}"));
@@ -1012,6 +1070,291 @@ impl EditorSession {
Ok(changed) Ok(changed)
} }
fn poll_gutter_at(&mut self, now: Instant) -> bool {
let line_gutter = self.editor.gutter_style() != crate::gutter::GutterStyle::None;
let picker_changed = match (self.editor.normal_file_picker(), &self.picker_source) {
(Some(picker), Some((tab, root))) => {
*tab != self.editor.tab_id() || *root != picker.root
}
(None, None) => false,
_ => true,
};
if self.editor.should_quit() || (!line_gutter && self.editor.normal_file_picker().is_none())
{
self.gutter_worker = None;
self.gutter_sources.clear();
self.gutter_pending = false;
self.picker_source = None;
return false;
}
let sources_changed = !self
.editor
.documents()
.filter(|document| line_gutter || document.dirty)
.filter_map(|document| {
document.path.map(|path| {
(
document.id,
document.revision,
document.save_revision,
path,
document.dirty,
)
})
})
.eq(self.gutter_sources.iter().map(|source| {
(
source.buffer,
source.revision,
source.save_revision,
source.path.as_path(),
source.dirty,
)
}));
let refresh_due = !self.gutter_pending
&& self
.last_gutter_refresh
.is_none_or(|last| now.saturating_duration_since(last) >= Duration::from_secs(1));
if sources_changed || picker_changed || refresh_due {
if picker_changed {
self.picker_source = self
.editor
.normal_file_picker()
.map(|picker| (self.editor.tab_id(), picker.root.clone()));
}
if sources_changed {
self.gutter_sources = self
.editor
.documents()
.filter(|document| line_gutter || document.dirty)
.filter_map(|document| {
let path = document.path?;
let contents = self
.gutter_sources
.binary_search_by_key(&document.id, |source| source.buffer)
.ok()
.map(|index| &self.gutter_sources[index])
.filter(|source| {
source.revision == document.revision && source.path == path
})
.map_or_else(
|| Arc::from(document.lines.join("\n")),
|source| Arc::clone(&source.contents),
);
Some(GutterSource {
buffer: document.id,
revision: document.revision,
save_revision: document.save_revision,
path: path.to_path_buf(),
dirty: document.dirty,
contents,
})
})
.collect();
}
self.gutter_request_id = self.gutter_request_id.wrapping_add(1);
self.last_gutter_refresh = Some(now);
if self.gutter_sources.is_empty() && self.picker_source.is_none() {
self.gutter_worker = None;
self.gutter_pending = false;
return false;
}
if self.gutter_worker.is_none() {
match crate::gutter::Worker::start() {
Ok(worker) => self.gutter_worker = Some(worker),
Err(error) => {
self.editor.set_message(format!("Gutter: {error}"));
return true;
}
}
}
let files = self
.gutter_sources
.iter()
.filter(|_| line_gutter)
.map(|source| {
(
source.buffer,
source.path.clone(),
Arc::clone(&source.contents),
)
})
.collect();
let picker = self.picker_source.as_ref().map(|(_, root)| {
let buffers = self
.gutter_sources
.iter()
.filter(|source| source.dirty)
.map(|source| (source.path.clone(), Arc::clone(&source.contents)))
.collect();
(root.clone(), buffers)
});
match self
.gutter_worker
.as_mut()
.expect("gutter worker started")
.request(self.gutter_request_id, files, picker)
{
Ok(()) => self.gutter_pending = true,
Err(error) => {
self.gutter_worker = None;
self.gutter_pending = false;
self.editor.set_message(format!("Gutter: {error}"));
return true;
}
}
}
let mut changed = false;
while let Some((id, result, picker)) = self
.gutter_worker
.as_mut()
.and_then(crate::gutter::Worker::poll)
{
changed |= self.apply_gutter_result(id, result);
if let Some(picker) = picker {
changed |= self.apply_picker_result(id, picker);
}
}
changed
}
fn apply_gutter_result(&mut self, id: u64, result: crate::gutter::ResultBatch) -> bool {
if id != self.gutter_request_id {
return false;
}
self.gutter_pending = false;
let mut changed = false;
for (buffer, result) in result {
let Some(source) = self
.gutter_sources
.iter()
.find(|source| source.buffer == buffer)
else {
continue;
};
let changes = match result {
Ok(changes) => changes,
Err(error) => {
self.editor.set_message(format!("Gutter: {error}"));
changed = true;
Vec::new()
}
};
changed |=
self.editor
.set_gutter_changes(buffer, source.revision, &source.path, changes);
}
changed
}
fn apply_picker_result(&mut self, id: u64, reply: crate::gutter::PickerChanges) -> bool {
let Some((tab, root)) = &self.picker_source else {
return false;
};
if id != self.gutter_request_id
|| *tab != self.editor.tab_id()
|| *root != reply.root
|| self
.editor
.normal_file_picker()
.is_none_or(|picker| picker.root != *root)
{
return false;
}
let tab = *tab;
let mut changed = false;
let changes = match reply.result {
Ok(changes) => changes,
Err(error) => {
self.editor.set_message(format!("File status: {error}"));
changed = true;
Vec::new()
}
};
changed
| self
.editor
.set_picker_repository_changes(tab, &reply.root, changes)
}
pub(crate) fn refresh_gutter(&mut self) -> bool {
self.last_gutter_refresh = None;
self.poll_gutter_at(Instant::now())
}
fn poll_blame(&mut self) -> bool {
let mut changed = false;
if !self
.blame_source
.as_ref()
.is_some_and(|source| self.editor.blame_file_source_is_current(source))
{
self.blame_source = self.editor.blame_source();
self.blame_request_id = self.blame_request_id.wrapping_add(1);
self.blame_lines = None;
changed |= self.editor.clear_line_blame();
if let Some(source) = &self.blame_source {
if self.blame_worker.is_none() {
self.blame_worker = BlameWorker::start().ok();
}
if let Some(worker) = &mut self.blame_worker
&& worker
.request(
self.blame_request_id,
source.path.to_path_buf(),
self.editor.lines().join("\n"),
)
.is_err()
{
self.blame_worker = None;
}
} else {
self.blame_worker = None;
}
}
while let Some((id, result)) = self.blame_worker.as_mut().and_then(BlameWorker::poll) {
changed |= self.apply_blame_result(id, result);
}
changed | self.refresh_line_blame()
}
fn apply_blame_result(
&mut self,
id: u64,
result: Result<Option<Vec<Option<Arc<crate::blame::LineBlame>>>>, String>,
) -> bool {
if id != self.blame_request_id
|| !self
.blame_source
.as_ref()
.is_some_and(|source| self.editor.blame_file_source_is_current(source))
{
return false;
}
self.blame_lines = result.ok().flatten();
self.refresh_line_blame()
}
fn refresh_line_blame(&mut self) -> bool {
if let Some(source) = &self.blame_source
&& self.editor.blame_file_source_is_current(source)
&& let Some(blame) = self
.blame_lines
.as_ref()
.and_then(|lines| lines.get(self.editor.cursor().row))
.and_then(Option::as_ref)
{
self.editor.set_cached_line_blame(source, blame.clone())
} else {
self.editor.clear_line_blame()
}
}
pub(crate) fn refresh_blame(&mut self) -> bool {
self.blame_source = None;
self.poll_blame()
}
fn poll_battery(&mut self) -> bool { fn poll_battery(&mut self) -> bool {
let enabled = self.editor.label("battery").is_empty() let enabled = self.editor.label("battery").is_empty()
&& [ && [
@@ -1056,6 +1399,15 @@ impl EditorSession {
self.editor.refresh_files() self.editor.refresh_files()
} }
fn refresh_checkout_diffs_at(&mut self, now: Instant) {
if now.saturating_duration_since(self.last_diff_refresh) < Duration::from_secs(1) {
return;
}
self.last_diff_refresh = now;
self.editor
.queue_checkout_refreshes(self.diff_follow_working_copy);
}
/// Drains ready plugin work without waiting for command or server responses. /// Drains ready plugin work without waiting for command or server responses.
pub fn poll(&mut self) -> bool { pub fn poll(&mut self) -> bool {
self.poll_at(Instant::now()) self.poll_at(Instant::now())
@@ -1063,8 +1415,10 @@ impl EditorSession {
fn poll_at(&mut self, now: Instant) -> bool { fn poll_at(&mut self, now: Instant) -> bool {
let mut changed = self.refresh_files_at(now); let mut changed = self.refresh_files_at(now);
changed |= self.poll_gutter_at(now);
changed |= self.synchronize_documents(); changed |= self.synchronize_documents();
changed |= self.poll_battery(); changed |= self.poll_battery();
self.refresh_checkout_diffs_at(now);
changed |= self.poll_diff(); changed |= self.poll_diff();
for update in self.lsp.poll() { for update in self.lsp.poll() {
match self.apply_update("core", update) { match self.apply_update("core", update) {
@@ -1127,6 +1481,410 @@ mod tests {
sync::atomic::{AtomicU64, Ordering}, sync::atomic::{AtomicU64, Ordering},
}; };
#[test]
fn normal_picker_tracks_unsaved_text_and_head_with_line_gutter_disabled() {
use crate::diff::FileStatus;
let fixture = Fixture::new();
fixture.file("buffer.txt", "base\n");
fixture.file("clean.txt", "clean\n");
let git = |args: &[&str]| {
let output = std::process::Command::new("git")
.args(args)
.current_dir(&fixture.0)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_AUTHOR_NAME", "Author")
.env("GIT_AUTHOR_EMAIL", "author@example.com")
.env("GIT_COMMITTER_NAME", "Author")
.env("GIT_COMMITTER_EMAIL", "author@example.com")
.env("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
};
git(&["init", "-q", "--template="]);
git(&["add", "buffer.txt", "clean.txt"]);
git(&["commit", "-qm", "baseline"]);
fixture.file("buffer.txt", "disk\n");
fixture.file("added.txt", "new\n");
let mut session = fixture.open(
"Vamp.configure do |config|\nconfig.git_gutter = \"none\"\nconfig.git_blame = false\nend\n",
);
session.editor.set_project_root(fixture.0.clone());
let now = session.last_file_refresh;
let open = |session: &mut EditorSession| {
session.handle(Input::Char(' '));
session.handle(Input::Char('o'));
};
let ready = |session: &mut EditorSession, clock: Instant, expected| {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
session.poll_gutter_at(clock);
let picker = session.editor.normal_file_picker().unwrap();
let status = picker
.entries()
.find(|row| row.path == Path::new("buffer.txt"))
.expect("normal picker retained the buffer file")
.status;
if !session.gutter_pending && status == expected {
assert!(
picker.entries().any(|row| {
row.path == Path::new("clean.txt") && row.status.is_none()
})
);
assert!(picker.entries().any(|row| {
row.path == Path::new("added.txt") && row.status == Some(FileStatus::Added)
}));
break;
}
assert!(
Instant::now() < deadline,
"normal picker status did not settle: {}",
session.editor.message()
);
thread::sleep(Duration::from_millis(5));
}
};
open(&mut session);
ready(&mut session, now, Some(FileStatus::Modified));
session.handle(Input::Ctrl('w'));
session.poll_gutter_at(now);
session
.editor
.apply(vec![Effect::Replace("base\n".into())])
.unwrap();
assert!(session.editor.dirty());
open(&mut session);
ready(&mut session, now, None);
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"disk\n"
);
session.handle(Input::Ctrl('w'));
session.poll_gutter_at(now);
session.handle(Input::Char('u'));
open(&mut session);
ready(&mut session, now, Some(FileStatus::Modified));
git(&["add", "buffer.txt"]);
git(&["commit", "-qm", "new baseline"]);
ready(&mut session, now + Duration::from_secs(1), None);
}
#[test]
fn gutter_results_follow_buffer_identity_and_reject_stale_edits() {
use crate::gutter::Change;
let fixture = Fixture::new();
let first_path = fixture.file("first.txt", "first\nsecond");
let second_path = fixture.file("second.txt", "other");
let mut session = EditorSession::open(Some(first_path), vec![], None).unwrap();
let now = session.last_gutter_refresh.unwrap();
let first_buffer = session.editor.buffer_id();
let first_request = session.gutter_request_id;
assert!(session.apply_gutter_result(
first_request,
vec![(first_buffer, Ok(vec![None, Some(Change::Modified)]))],
));
session.editor.handle(Input::Down);
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
session
.editor
.execute(&format!("edit {}", second_path.display()));
session.poll_gutter_at(now);
let second_buffer = session.editor.buffer_id();
let second_request = session.gutter_request_id;
assert_ne!(second_request, first_request);
assert!(!session.apply_gutter_result(
first_request,
vec![(first_buffer, Ok(vec![Some(Change::Removed), None]))],
));
assert!(session.apply_gutter_result(
second_request,
vec![
(first_buffer, Ok(vec![None, Some(Change::Modified)])),
(second_buffer, Ok(vec![Some(Change::Added)])),
],
));
assert_eq!(session.editor.gutter_change(0), Some(Change::Added));
session.editor.execute(&format!("buffer {first_buffer}"));
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
session.editor.handle(Input::Char('i'));
session.editor.handle(Input::Char('x'));
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
session.editor.handle(Input::Char('y'));
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
session.editor.handle(Input::Backspace);
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
session.editor.handle(Input::Escape);
session.editor.handle(Input::Char('u'));
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
session.editor.handle(Input::Char('U'));
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
assert!(!session.apply_gutter_result(
second_request,
vec![(first_buffer, Ok(vec![None, Some(Change::Removed)]))],
));
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
session.poll_gutter_at(now);
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
assert!(session.apply_gutter_result(
session.gutter_request_id,
vec![(first_buffer, Ok(vec![None, Some(Change::Added)]))],
));
assert_eq!(session.editor.gutter_change(1), Some(Change::Added));
session.poll_gutter_at(now + Duration::from_secs(1));
assert!(session.apply_gutter_result(
session.gutter_request_id,
vec![(first_buffer, Ok(vec![None, None]))],
));
assert_eq!(session.editor.gutter_change(1), None);
session.editor.execute(&format!("buffer {second_buffer}"));
assert_eq!(session.editor.gutter_change(0), Some(Change::Added));
}
#[test]
fn checkout_diff_polling_uses_injected_interval_independently_of_endpoint_following() {
use crate::diff::{Catalog, Category, Comparison, RepoKind, Revision, RevisionTarget};
let fixture = Fixture::new();
fixture.file("buffer.txt", "base");
for working in [true, false] {
let mut session = fixture.open("");
session.editor.execute("diff");
let (id, _) = session.editor.take_diff_request().unwrap();
session.editor.apply_diff_result(
id,
Ok(DiffOutcome::Catalog(Catalog {
kind: RepoKind::Git,
notice: None,
entries: [
RevisionTarget::Working,
RevisionTarget::Commit("base".into()),
]
.into_iter()
.map(|target| Revision {
target,
label: "revision".into(),
description: String::new(),
category: Category::Commits,
})
.collect(),
})),
);
session.editor.handle(Input::Down);
session.editor.handle(Input::Enter);
if !working {
session.editor.handle(Input::Down);
}
session.editor.handle(Input::Enter);
let (id, _) = session.editor.take_diff_request().unwrap();
session.editor.apply_diff_result(
id,
Ok(DiffOutcome::Comparison(Comparison {
root: fixture.0.clone(),
before: "base".into(),
after: "after".into(),
files: Vec::new(),
editable_paths: Default::default(),
})),
);
let now = session.last_diff_refresh;
for (tick, follow) in [(1, false), (2, true)] {
session.diff_follow_working_copy = follow;
session.refresh_checkout_diffs_at(now + Duration::from_millis(tick * 1000 - 1));
assert!(session.editor.take_next_diff_request().is_none());
session.refresh_checkout_diffs_at(now + Duration::from_secs(tick));
let request = session.editor.take_next_diff_request();
assert_eq!(request.is_some(), !working || follow);
if let Some((_, refresh, operation)) = request {
assert!(
matches!(operation, DiffOperation::RefreshCheckout { comparison_id, follow_working, .. }
if comparison_id == id && follow_working == follow)
);
assert!(
!session
.editor
.apply_diff_result(refresh, Ok(DiffOutcome::CheckoutRefresh(None)))
);
}
}
}
}
#[test]
fn blame_sources_are_deduplicated_and_stale_results_cannot_reappear() {
let fixture = Fixture::new();
let path = fixture.file("source.txt", "first\nsecond\nthird");
let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap();
assert!(session.editor.git_blame());
let first = session.blame_request_id;
let original = Arc::new(crate::blame::LineBlame {
commit: "0123456789".into(),
author: "Author".into(),
summary: "Original".into(),
uncommitted: false,
});
let second = Arc::new(crate::blame::LineBlame {
commit: "0123456789".into(),
author: "Author".into(),
summary: "Second".into(),
uncommitted: false,
});
let blame = || {
Ok(Some(vec![
Some(original.clone()),
Some(second.clone()),
Some(second.clone()),
]))
};
session.handle(Input::Down);
assert_eq!(session.blame_request_id, first);
session.blame_worker = None;
assert!(session.apply_blame_result(first, blame()));
assert_eq!(
session.editor.line_blame(),
Some("Author · Second (01234567)")
);
let label = session.editor.line_blame().unwrap().as_ptr();
session.handle(Input::Down);
assert_eq!(session.editor.line_blame().unwrap().as_ptr(), label);
assert!(!session.poll_blame());
session.handle(Input::Up);
session.handle(Input::Up);
assert_eq!(
session.editor.line_blame(),
Some("Author · Original (01234567)")
);
assert_eq!(session.blame_request_id, first);
session.execute("tab.new");
assert_eq!(session.blame_request_id, first);
assert_eq!(
session.editor.line_blame(),
Some("Author · Original (01234567)")
);
let other = fixture.file("other.txt", "other");
session.execute(&format!("edit {}", other.display()));
assert!(session.editor.line_blame().is_none());
assert!(!session.apply_blame_result(first, blame()));
session.execute("buffer 1");
assert!(!session.apply_blame_result(first, blame()));
let source = session.editor.blame_source().unwrap();
let before_edit = session.blame_request_id;
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
assert_ne!(session.blame_request_id, before_edit);
assert!(!session.apply_blame_result(before_edit, blame()));
assert!(!session.editor.set_line_blame(source, original.clone()));
assert!(session.editor.line_blame().is_none());
session.handle(Input::Escape);
session.execute("git_blame.toggle");
assert!(!session.editor.git_blame());
assert!(session.blame_worker.is_none());
assert!(session.blame_source.is_none());
assert!(session.editor.line_blame().is_none());
assert!(!session.apply_blame_result(session.blame_request_id, blame()));
session.poll_blame();
assert!(session.blame_worker.is_none());
let config = fixture.file(
"config.rb",
"Vamp.configure { |config| config.git_blame = false }",
);
let mut disabled = EditorSession::open(Some(path), vec![], Some(&config)).unwrap();
assert!(!disabled.editor.git_blame());
assert!(disabled.blame_worker.is_none());
disabled.poll_blame();
assert!(disabled.blame_worker.is_none());
}
#[test]
fn blame_navigation_uses_one_real_file_result_and_refreshes_its_baseline() {
let fixture = Fixture::new();
let path = fixture.file("source.txt", "first\nsecond\n");
let git = |args: &[&str]| {
let output = std::process::Command::new("git")
.args(args)
.current_dir(&fixture.0)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_AUTHOR_NAME", "Author")
.env("GIT_AUTHOR_EMAIL", "author@example.com")
.env("GIT_COMMITTER_NAME", "Author")
.env("GIT_COMMITTER_EMAIL", "author@example.com")
.env("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
};
git(&["init", "-q"]);
git(&["add", "source.txt"]);
git(&["commit", "-qm", "Original"]);
fixture.file("source.txt", "first\nchanged\n");
git(&["add", "source.txt"]);
git(&["commit", "-qm", "Second"]);
let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap();
let ready = |session: &mut EditorSession| {
let deadline = Instant::now() + Duration::from_secs(10);
while session.blame_lines.is_none() {
assert!(Instant::now() < deadline, "file attribution did not finish");
session.poll();
thread::sleep(Duration::from_millis(5));
}
};
let first = session.blame_request_id;
session.handle(Input::Down);
assert_eq!(session.blame_request_id, first);
ready(&mut session);
assert!(session.editor.line_blame().unwrap().contains("Second"));
session.handle(Input::Up);
assert!(session.editor.line_blame().unwrap().contains("Original"));
session.handle(Input::Down);
assert!(session.editor.line_blame().unwrap().contains("Second"));
session.handle(Input::Down);
assert!(session.editor.line_blame().is_none());
assert_eq!(session.blame_request_id, first);
session.handle(Input::Up);
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
assert_ne!(session.blame_request_id, first);
ready(&mut session);
assert_eq!(session.editor.line_blame(), Some("Uncommitted"));
session.handle(Input::Escape);
let edited = session.blame_request_id;
session.execute("save");
assert_ne!(session.blame_request_id, edited);
ready(&mut session);
git(&["add", "source.txt"]);
git(&["commit", "-qm", "Saved"]);
let saved = session.blame_request_id;
session.refresh_blame();
assert_ne!(session.blame_request_id, saved);
ready(&mut session);
assert!(session.editor.line_blame().unwrap().contains("Saved"));
fixture.file("source.txt", "first\nxchanged\n");
let refreshed = session.blame_request_id;
assert!(!session.editor.refresh_files());
session.synchronize_documents();
assert_ne!(session.blame_request_id, refreshed);
ready(&mut session);
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
ready(&mut session);
assert!(session.editor.dirty());
fs::remove_file(path).unwrap();
assert!(session.editor.refresh_files());
session.synchronize_documents();
assert!(session.blame_source.is_none());
assert!(session.editor.line_blame().is_none());
}
#[test] #[test]
fn native_navigation_resolves_utf16_and_validates_full_ranges_atomically() { fn native_navigation_resolves_utf16_and_validates_full_ranges_atomically() {
use crate::plugin::Utf16Range; use crate::plugin::Utf16Range;
+109 -20
View File
@@ -24,7 +24,7 @@ pub enum SyntaxKind {
Markup, Markup,
} }
/// A syntax foreground range in the original line's bytes, excluding its newline. /// A syntax range in original line bytes; string coverage may include its newline.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SyntaxSpan { pub struct SyntaxSpan {
pub start_byte: usize, pub start_byte: usize,
@@ -37,12 +37,14 @@ pub struct SyntaxSpan {
pub struct SyntaxCache { pub struct SyntaxCache {
revision: Option<u64>, revision: Option<u64>,
path: Option<PathBuf>, path: Option<PathBuf>,
final_newline: bool,
syntax: Option<&'static LanguageSpec>, syntax: Option<&'static LanguageSpec>,
source: String, source: String,
next_source: String, next_source: String,
line_starts: Vec<usize>, line_starts: Vec<usize>,
lines: Vec<Vec<SyntaxSpan>>, lines: Vec<Vec<SyntaxSpan>>,
highlighted: Vec<bool>, highlighted: Vec<bool>,
string_spans: Vec<Vec<SyntaxSpan>>,
context: Option<LayerContext>, context: Option<LayerContext>,
parser: Parser, parser: Parser,
tree: Option<Tree>, tree: Option<Tree>,
@@ -88,6 +90,7 @@ impl SyntaxCache {
path, path,
revision, revision,
std::slice::from_ref(&(0..lines.len())), std::slice::from_ref(&(0..lines.len())),
false,
); );
} }
@@ -100,7 +103,26 @@ impl SyntaxCache {
revision: u64, revision: u64,
visible: &[std::ops::Range<usize>], visible: &[std::ops::Range<usize>],
) { ) {
self.update_rows(lines, path, revision, visible); self.update_rows(lines, path, revision, visible, false);
}
/// Diff sources store a final line break separately instead of a trailing empty row.
pub(crate) fn update_diff(
&mut self,
lines: &[String],
path: Option<&Path>,
revision: u64,
final_newline: bool,
visible: Option<&[std::ops::Range<usize>]>,
) {
let all_rows = 0..lines.len();
self.update_rows(
lines,
path,
revision,
visible.unwrap_or(std::slice::from_ref(&all_rows)),
final_newline && !lines.is_empty(),
);
} }
fn update_rows( fn update_rows(
@@ -109,9 +131,11 @@ impl SyntaxCache {
path: Option<&Path>, path: Option<&Path>,
revision: u64, revision: u64,
visible: &[std::ops::Range<usize>], visible: &[std::ops::Range<usize>],
final_newline: bool,
) { ) {
let path_changed = self.path.as_deref() != path; let path_changed = self.path.as_deref() != path;
let revision_changed = self.revision != Some(revision); let revision_changed =
self.revision != Some(revision) || self.final_newline != final_newline;
let syntax = if path_changed || revision_changed { let syntax = if path_changed || revision_changed {
languages::detect(path, lines.first().map(String::as_str)) languages::detect(path, lines.first().map(String::as_str))
} else { } else {
@@ -119,6 +143,7 @@ impl SyntaxCache {
}; };
let language_changed = !same_language(self.syntax, syntax); let language_changed = !same_language(self.syntax, syntax);
self.revision = Some(revision); self.revision = Some(revision);
self.final_newline = final_newline;
if path_changed { if path_changed {
self.path = path.map(Path::to_path_buf); self.path = path.map(Path::to_path_buf);
} }
@@ -127,8 +152,9 @@ impl SyntaxCache {
let mut edit = None; let mut edit = None;
if revision_changed { if revision_changed {
self.next_source.clear(); self.next_source.clear();
let length = let length = lines.iter().map(String::len).sum::<usize>()
lines.iter().map(String::len).sum::<usize>() + lines.len().saturating_sub(1); + lines.len().saturating_sub(1)
+ usize::from(final_newline);
self.next_source.reserve(length); self.next_source.reserve(length);
for (row, line) in lines.iter().enumerate() { for (row, line) in lines.iter().enumerate() {
if row > 0 { if row > 0 {
@@ -136,6 +162,9 @@ impl SyntaxCache {
} }
self.next_source.push_str(line); self.next_source.push_str(line);
} }
if final_newline {
self.next_source.push('\n');
}
if self.source != self.next_source { if self.source != self.next_source {
edit = Some(minimal_edit(&self.source, &self.next_source)); edit = Some(minimal_edit(&self.source, &self.next_source));
std::mem::swap(&mut self.source, &mut self.next_source); std::mem::swap(&mut self.source, &mut self.next_source);
@@ -146,11 +175,15 @@ impl SyntaxCache {
self.line_starts.push(start); self.line_starts.push(start);
start += line.len() + 1; start += line.len() + 1;
} }
if final_newline {
self.line_starts.push(self.source.len());
}
self.lines.resize_with(lines.len(), Vec::new); self.lines.resize_with(lines.len(), Vec::new);
self.string_spans.resize_with(lines.len(), Vec::new);
self.highlighted.resize(lines.len(), false); self.highlighted.resize(lines.len(), false);
} }
if edit.is_some() || language_changed { if edit.is_some() || language_changed {
for spans in &mut self.lines { for spans in self.lines.iter_mut().chain(&mut self.string_spans) {
spans.clear(); spans.clear();
} }
self.highlighted.fill(false); self.highlighted.fill(false);
@@ -183,6 +216,8 @@ impl SyntaxCache {
start_point: Point::new(start, 0), start_point: Point::new(start, 0),
end_point: if row < lines.len() { end_point: if row < lines.len() {
Point::new(row, 0) Point::new(row, 0)
} else if final_newline {
Point::new(lines.len(), 0)
} else { } else {
Point::new( Point::new(
lines.len().saturating_sub(1), lines.len().saturating_sub(1),
@@ -209,7 +244,7 @@ impl SyntaxCache {
self.injections.clear(); self.injections.clear();
self.context = None; self.context = None;
self.highlighted.fill(false); self.highlighted.fill(false);
for spans in &mut self.lines { for spans in self.lines.iter_mut().chain(&mut self.string_spans) {
spans.clear(); spans.clear();
} }
} else { } else {
@@ -225,6 +260,12 @@ impl SyntaxCache {
self.lines.get(row).map_or(&[], Vec::as_slice) self.lines.get(row).map_or(&[], Vec::as_slice)
} }
/// String coverage before foreground overrides, including embedded-language highlighting.
/// An included line break has end_byte == line.len() + 1; foreground spans exclude it.
pub(crate) fn string_spans(&self, row: usize) -> &[SyntaxSpan] {
self.string_spans.get(row).map_or(&[], Vec::as_slice)
}
/// Bundled grammar name, or `None` for unknown languages and plain text. /// Bundled grammar name, or `None` for unknown languages and plain text.
pub fn language(&self) -> Option<&str> { pub fn language(&self) -> Option<&str> {
self.syntax.map(|syntax| syntax.name) self.syntax.map(|syntax| syntax.name)
@@ -415,6 +456,7 @@ impl SyntaxCache {
&self.source, &self.source,
&self.line_starts, &self.line_starts,
&mut self.lines, &mut self.lines,
&mut self.string_spans,
&self.highlights, &self.highlights,
&mut self.events, &mut self.events,
visible, visible,
@@ -1089,6 +1131,7 @@ fn render_spans(
source: &str, source: &str,
starts: &[usize], starts: &[usize],
lines: &mut [Vec<SyntaxSpan>], lines: &mut [Vec<SyntaxSpan>],
strings: &mut [Vec<SyntaxSpan>],
highlights: &[Highlight], highlights: &[Highlight],
events: &mut Vec<(usize, bool, usize)>, events: &mut Vec<(usize, bool, usize)>,
visible: &[Range], visible: &[Range],
@@ -1111,24 +1154,34 @@ fn render_spans(
}; };
let mut active: BTreeSet<(_, usize)> = BTreeSet::new(); let mut active: BTreeSet<(_, usize)> = BTreeSet::new();
let mut previous = 0; let mut previous = 0;
let mut active_strings = 0;
for &(byte, entering, index) in events.iter() { for &(byte, entering, index) in events.iter() {
if previous < byte if previous < byte {
&& let Some((_, current)) = active.last() let kind = active
&& let Some(kind) = highlights[*current].kind .last()
{ .and_then(|(_, current)| highlights[*current].kind);
if kind.is_some() || active_strings > 0 {
let start = visible.partition_point(|range| range.end_byte <= previous); let start = visible.partition_point(|range| range.end_byte <= previous);
for range in visible[start..] for range in visible[start..]
.iter() .iter()
.take_while(|range| range.start_byte < byte) .take_while(|range| range.start_byte < byte)
{ {
append_global( let low = previous.max(range.start_byte);
source, let high = byte.min(range.end_byte);
starts, if let Some(kind) = kind {
lines, append_global(source, starts, lines, low, high, kind, false);
previous.max(range.start_byte), }
byte.min(range.end_byte), if active_strings > 0 {
kind, append_global(source, starts, strings, low, high, SyntaxKind::String, true);
); }
}
}
}
if highlights[index].kind == Some(SyntaxKind::String) {
if entering {
active_strings += 1;
} else {
active_strings -= 1;
} }
} }
if entering { if entering {
@@ -1147,6 +1200,7 @@ fn append_global(
mut start: usize, mut start: usize,
end: usize, end: usize,
kind: SyntaxKind, kind: SyntaxKind,
include_newline: bool,
) { ) {
if starts.is_empty() { if starts.is_empty() {
return; return;
@@ -1156,7 +1210,7 @@ fn append_global(
.saturating_sub(1); .saturating_sub(1);
while start < end && row < lines.len() { while start < end && row < lines.len() {
let line_end = starts.get(row + 1).map_or(source.len(), |next| next - 1); let line_end = starts.get(row + 1).map_or(source.len(), |next| next - 1);
let high = end.min(line_end); let high = end.min(line_end + usize::from(include_newline && row + 1 < starts.len()));
if start < high { if start < high {
let spans = &mut lines[row]; let spans = &mut lines[row];
let low = start - starts[row]; let low = start - starts[row];
@@ -1196,6 +1250,38 @@ mod tests {
} }
} }
#[test]
fn string_newlines_remain_literal_across_visible_row_caching_and_edits() {
let source = "text = \"\"\"a\n\nb\"\"\"\nplain\n";
let input = lines(source);
let path = Path::new("strings.py");
let mut cache = SyntaxCache::new();
let includes_newline = |cache: &SyntaxCache, row: usize, length: usize| {
cache
.string_spans(row)
.iter()
.any(|span| span.start_byte <= length && span.end_byte > length)
};
cache.update_visible(&input, Some(path), 0, std::slice::from_ref(&(0..1)));
assert!(includes_newline(&cache, 0, input[0].len()));
cache.update_visible(&input, Some(path), 0, std::slice::from_ref(&(1..4)));
assert!(includes_newline(&cache, 0, input[0].len()));
assert!(includes_newline(&cache, 1, input[1].len()));
assert!(!includes_newline(&cache, 2, input[2].len()));
assert!(!includes_newline(&cache, 3, input[3].len()));
for (row, line) in input.iter().enumerate() {
assert!(
cache
.line(row)
.iter()
.all(|span| span.end_byte <= line.len())
);
}
cache.update(&lines("# a\n\nb\nplain\n"), Some(path), 1);
assert!(!includes_newline(&cache, 0, 3));
assert!(!includes_newline(&cache, 1, 0));
}
#[test] #[test]
fn visible_windows_match_full_colors_after_edits_and_scrolling_without_reparsing() { fn visible_windows_match_full_colors_after_edits_and_scrolling_without_reparsing() {
let cases = [ let cases = [
@@ -1893,6 +1979,7 @@ mod tests {
source, source,
&[0], &[0],
&mut spans, &mut spans,
&mut vec![Vec::new(); 1],
&highlights, &highlights,
&mut events, &mut events,
&[source_range(source)], &[source_range(source)],
@@ -1963,6 +2050,7 @@ mod tests {
source, source,
&starts, &starts,
&mut spans, &mut spans,
&mut vec![Vec::new(); 3],
&highlights, &highlights,
&mut Vec::new(), &mut Vec::new(),
&[source_range(source)], &[source_range(source)],
@@ -2004,6 +2092,7 @@ mod tests {
source, source,
&starts, &starts,
&mut visible_spans, &mut visible_spans,
&mut vec![Vec::new(); 3],
&visible_highlights, &visible_highlights,
&mut Vec::new(), &mut Vec::new(),
&window, &window,
+3080 -163
View File
File diff suppressed because it is too large Load Diff
+126 -1
View File
@@ -7,7 +7,11 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use vamp::{diff::Phase, editor::Input, session::EditorSession}; use vamp::{
diff::{ComparisonMode, Phase},
editor::Input,
session::EditorSession,
};
static NEXT: AtomicU64 = AtomicU64::new(0); static NEXT: AtomicU64 = AtomicU64::new(0);
@@ -111,6 +115,10 @@ fn typed_diff_edits_unsaved_working_after_and_preserves_undo_on_explicit_close()
session.handle(Input::Enter); session.handle(Input::Enter);
assert_eq!(session.editor().diff().unwrap().phase, Phase::After); assert_eq!(session.editor().diff().unwrap().phase, Phase::After);
session.handle(Input::Enter); session.handle(Input::Enter);
assert_eq!(
session.editor().diff().unwrap().comparison_mode,
ComparisonMode::Snapshots
);
wait_until(&mut session, |session| { wait_until(&mut session, |session| {
session session
.editor() .editor()
@@ -485,3 +493,120 @@ fn readonly_jump_history_restores_the_comparison_file_and_source_cursor() {
fs::read_to_string(root.join("first.rs")).unwrap() fs::read_to_string(root.join("first.rs")).unwrap()
); );
} }
#[test]
fn branch_changes_default_and_mode_switches_compare_the_selected_divergent_tips() {
let fixture = Fixture::new();
let root = fixture.0.join("repo");
fixture.git(&["init", "-q"]);
fs::write(root.join("common"), "common\n").unwrap();
fixture.git(&["add", "."]);
fixture.git(&["commit", "-qm", "common"]);
let base = fixture.git(&["rev-parse", "HEAD"]);
fs::write(root.join("base-only"), "base branch\n").unwrap();
fixture.git(&["add", "."]);
fixture.git(&["commit", "-qm", "base only"]);
let before = fixture.git(&["rev-parse", "HEAD"]);
fixture.git(&["checkout", "-qb", "feature", &base]);
fs::write(root.join("feature-only"), "feature branch\n").unwrap();
fixture.git(&["add", "."]);
fixture.git(&["commit", "-qm", "feature only"]);
let after = fixture.git(&["rev-parse", "HEAD"]);
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();
let mut session =
EditorSession::open(Some(root.join("common")), vec![], Some(&config)).unwrap();
session.execute("diff");
wait_until(&mut session, |session| {
!session.editor().diff().unwrap().loading
});
assert_eq!(
session.editor().diff().unwrap().comparison_mode,
ComparisonMode::BranchChanges
);
session.handle(Input::Ctrl('b'));
assert_eq!(
session.editor().diff().unwrap().comparison_mode,
ComparisonMode::Snapshots
);
session.handle(Input::Ctrl('p'));
type_query(&mut session, "diff.mode.toggle");
session.handle(Input::Enter);
assert_eq!(
session.editor().diff().unwrap().comparison_mode,
ComparisonMode::BranchChanges
);
for id in [&before, &after] {
type_query(&mut session, id);
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();
let comparison = diff.comparison.as_ref().unwrap();
assert_eq!(comparison.files.len(), 1);
assert_eq!(comparison.files[0].path, Path::new("feature-only"));
assert_eq!(session.editor().context().text, "feature branch\n");
assert!(diff.after_editable());
assert_eq!(
comparison.before,
format!("merge base of {before} and {after} · {base}")
);
assert_eq!(diff.before.as_ref().unwrap().label, before);
session.execute("diff.mode.toggle");
wait_until(&mut session, |session| {
session.editor().diff().unwrap().selected_file_loaded()
});
let comparison = session
.editor()
.diff()
.unwrap()
.comparison
.as_ref()
.unwrap();
assert_eq!(comparison.before, before);
assert_eq!(comparison.after, after);
assert_eq!(
comparison
.files
.iter()
.map(|file| file.path.as_path())
.collect::<Vec<_>>(),
[Path::new("base-only"), Path::new("feature-only")]
);
// Superseding an in-flight recomparison must not restore the old mode's result.
session.execute("diff.mode.toggle");
session.execute("diff.mode.toggle");
session.execute("diff.mode.toggle");
wait_until(&mut session, |session| {
session.editor().diff().unwrap().selected_file_loaded()
});
let diff = session.editor().diff().unwrap();
assert_eq!(diff.comparison_mode, ComparisonMode::BranchChanges);
assert_eq!(diff.comparison.as_ref().unwrap().files.len(), 1);
assert_eq!(diff.before.as_ref().unwrap().label, before);
assert_eq!(diff.after.as_ref().unwrap().label, after);
session.execute("diff.close");
session.execute("diff");
wait_until(&mut session, |session| {
!session.editor().diff().unwrap().loading
});
session.handle(Input::Enter);
let diff = session.editor().diff().unwrap();
assert_eq!(diff.phase, Phase::After);
assert_eq!(diff.comparison_mode, ComparisonMode::Snapshots);
session.execute("diff.mode.toggle");
assert_eq!(
session.editor().diff().unwrap().comparison_mode,
ComparisonMode::Snapshots
);
assert_eq!(
session.editor().message(),
"Working checkout requires Snapshots"
);
}
+289
View File
@@ -0,0 +1,289 @@
use std::{
fs,
path::{Path, PathBuf},
process::Command,
sync::atomic::{AtomicU64, Ordering},
thread,
time::{Duration, Instant},
};
use vamp::{
diff::{Category, Phase, RevisionTarget},
editor::Input,
session::EditorSession,
};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Fixture {
root: PathBuf,
target: String,
decoy: String,
}
impl Fixture {
fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"vamp-diff-search-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed),
));
fs::create_dir_all(root.join("repo")).unwrap();
let mut fixture = Self {
root,
target: String::new(),
decoy: String::new(),
};
fixture.git(&["init", "-q", "--initial-branch=fixture-main"]);
fixture.commit("foundation", "base");
fixture.target = fixture.commit("Über parser target", "target");
fixture.decoy = fixture.commit(
&format!("release archaeology mentioning {}", fixture.target),
"decoy",
);
for name in [
"a-r_e_l_e_a_s_e",
"release-candidate",
"zrelease",
"tie-a",
"tie-b",
] {
fixture.git(&["branch", name, &fixture.decoy]);
fixture.git(&["tag", name, &fixture.decoy]);
}
for name in ["release", "feature/parser-rewrite", "feature/Über-parser"] {
fixture.git(&["branch", name, &fixture.target]);
fixture.git(&["tag", name, &fixture.target]);
}
fixture
}
fn git(&self, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(self.root.join("repo"))
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_AUTHOR_NAME", "Diff Search Fixture")
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
.env("GIT_COMMITTER_NAME", "Diff Search Fixture")
.env("GIT_COMMITTER_EMAIL", "fixture@example.invalid")
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}
fn commit(&self, message: &str, content: &str) -> String {
fs::write(self.root.join("repo/source.txt"), content).unwrap();
self.git(&["add", "source.txt"]);
self.git(&["commit", "-qm", message]);
self.git(&["rev-parse", "HEAD"])
}
fn session(&self) -> EditorSession {
let config = self.root.join("config.rb");
fs::write(
&config,
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\nend\n",
)
.unwrap();
let mut session = EditorSession::open(
Some(self.root.join("repo/source.txt")),
vec![],
Some(&config),
)
.unwrap();
assert!(session.editor().startup_diagnostic().is_none());
session.execute(":diff");
wait_until(&mut session, |session| {
session
.editor()
.diff()
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
});
session
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn wait_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) {
let deadline = Instant::now() + Duration::from_secs(10);
while !ready(session) {
session.poll();
assert!(Instant::now() < deadline, "{:?}", session.editor().diff());
if let Some(diff) = session.editor().diff() {
assert!(diff.error.is_none(), "{:?}", diff.error);
}
thread::sleep(Duration::from_millis(1));
}
}
fn query(session: &mut EditorSession, text: &str) {
session.handle(Input::Ctrl('u'));
for ch in text.chars() {
session.handle(Input::Char(ch));
}
}
fn category(session: &mut EditorSession, category: Category) {
query(session, "");
for _ in 0..3 {
let diff = session.editor().diff().unwrap();
if diff.catalog.as_ref().unwrap().kind.categories()[diff.category] == category {
return;
}
session.handle(Input::Char('\t'));
}
panic!("category unavailable: {category:?}");
}
fn labels(session: &EditorSession) -> Vec<String> {
let diff = session.editor().diff().unwrap();
assert_eq!(
diff.entries().next().unwrap().target,
RevisionTarget::Working
);
diff.entries()
.skip(1)
.map(|entry| entry.label.clone())
.collect()
}
#[test]
fn branch_and_tag_fuzzy_ranking_and_empty_order_match_in_both_selectors() {
let fixture = Fixture::new();
let mut session = fixture.session();
for phase in [Phase::Before, Phase::After] {
assert_eq!(session.editor().diff().unwrap().phase, phase);
for kind in [Category::Branches, Category::Tags] {
category(&mut session, kind);
let catalogue_order = labels(&session);
query(&mut session, " ");
assert_eq!(labels(&session), catalogue_order);
query(&mut session, "release");
assert_eq!(
labels(&session),
[
"release",
"release-candidate",
"zrelease",
"a-r_e_l_e_a_s_e"
]
);
query(&mut session, "ftprw");
assert_eq!(labels(&session), ["feature/parser-rewrite"]);
query(&mut session, "tie");
let ties: Vec<_> = catalogue_order
.iter()
.filter(|label| label.starts_with("tie-"))
.cloned()
.collect();
assert_eq!(&labels(&session)[..ties.len()], ties);
query(&mut session, "no-such-reference-anywhere");
assert!(labels(&session).is_empty());
assert_eq!(session.editor().diff().unwrap().selected, 0);
}
if phase == Phase::Before {
// Even an unmatched query leaves Working selectable and pinned first.
session.handle(Input::Enter);
}
}
}
#[test]
fn unicode_tokens_and_commit_ids_search_fields_without_cross_field_subsequences() {
let fixture = Fixture::new();
let mut session = fixture.session();
for phase in [Phase::Before, Phase::After] {
assert_eq!(session.editor().diff().unwrap().phase, phase);
category(&mut session, Category::Branches);
for text in [
format!("{} ÜBPR", &fixture.target[..10]),
format!("übpr {}", &fixture.target[..10]),
] {
query(&mut session, &text);
assert_eq!(labels(&session), ["feature/Über-parser"]);
}
category(&mut session, Category::Commits);
for text in [&fixture.target[..], &fixture.target[..10]] {
query(&mut session, text);
assert_eq!(
labels(&session),
[fixture.target.clone(), fixture.decoy.clone()]
);
}
query(&mut session, "ÜB PRSR");
assert_eq!(labels(&session), [fixture.target.clone()]);
// The ID and description can satisfy separate tokens, but not one token
// that would require a subsequence crossing between unrelated fields.
query(&mut session, &format!("{} übpr", &fixture.target[..10]));
assert_eq!(labels(&session), [fixture.target.clone()]);
query(&mut session, &format!("{}über", fixture.target));
assert!(labels(&session).is_empty());
if phase == Phase::Before {
session.handle(Input::Enter);
}
}
}
#[test]
fn selecting_ranked_branch_uses_its_actual_commit_in_before_and_after() {
let fixture = Fixture::new();
for ranked_phase in [Phase::Before, Phase::After] {
let mut session = fixture.session();
if ranked_phase == Phase::After {
session.handle(Input::Enter);
}
category(&mut session, Category::Branches);
query(&mut session, "release");
assert_eq!(labels(&session)[0], "release");
session.handle(Input::Down);
session.handle(Input::Enter);
if ranked_phase == Phase::Before {
// Working selects snapshot mode rather than merge-base mode.
session.handle(Input::Enter);
}
wait_until(&mut session, |session| {
session
.editor()
.diff()
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
});
let diff = session.editor().diff().unwrap();
let ranked = if ranked_phase == Phase::Before {
diff.before.as_ref().unwrap()
} else {
diff.after.as_ref().unwrap()
};
assert_eq!(ranked.label, "release");
assert_eq!(
ranked.target,
RevisionTarget::Commit(fixture.target.clone())
);
let comparison = diff.comparison.as_ref().unwrap();
assert_eq!(comparison.files.len(), 1);
assert_eq!(comparison.files[0].path, Path::new("source.txt"));
let (before, after) = if ranked_phase == Phase::Before {
(["target"], ["decoy"])
} else {
(["decoy"], ["target"])
};
assert_eq!(comparison.files[0].before, before);
assert_eq!(comparison.files[0].after, after);
}
}
+50
View File
@@ -0,0 +1,50 @@
use vamp::editor::{Cursor, Editor, Effect, Input, Mode};
fn editor() -> Editor {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![Effect::Replace(
"first\n e\u{301}second\n\tthird".into(),
)])
.unwrap();
editor
}
fn keys(editor: &mut Editor, text: &str) {
for key in text.chars() {
editor.handle(Input::Char(key));
}
}
#[test]
fn counted_g_is_one_based_clamps_and_leaves_bare_prefixes_intact() {
let mut editor = editor();
let revision = editor.text_revision();
keys(&mut editor, "2g");
assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 });
assert_eq!(editor.count(), None);
keys(&mut editor, "999999999999999999999999999999999g");
assert_eq!(editor.cursor(), Cursor { row: 2, column: 1 });
keys(&mut editor, "1g");
assert_eq!(editor.cursor().row, 0);
keys(&mut editor, "gb");
assert_eq!(editor.cursor().row, 2);
keys(&mut editor, "gt");
assert_eq!(editor.cursor().row, 0);
assert_eq!(editor.text_revision(), revision);
}
#[test]
fn counted_g_extends_visual_selection_and_respects_g_overrides() {
let mut editor = editor();
keys(&mut editor, "v3g");
assert_eq!(editor.mode(), Mode::Visual);
assert_eq!(editor.cursor(), Cursor { row: 2, column: 1 });
assert_eq!(editor.selections()[0].start, Cursor { row: 0, column: 0 });
editor.handle(Input::Escape);
keys(&mut editor, "gt");
editor.bind("normal", "g", None).unwrap();
keys(&mut editor, "2g");
assert_eq!(editor.cursor().row, 0);
editor
.bind("normal", "g", Some("core.down".into()))
.unwrap();
keys(&mut editor, "2g");
assert_eq!(editor.cursor().row, 2);
}
+16
View File
@@ -0,0 +1,16 @@
use vamp::editor::{Editor, Effect, Input};
#[test]
fn yy_copies_current_whole_line_for_paste_below_it() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_clipboard_mode("separate").unwrap();
editor
.apply(vec![Effect::Replace("first\nsecond\n".into())])
.unwrap();
for key in "llyy".chars() {
editor.handle(Input::Char(key));
}
assert_eq!(editor.cursor().column, 2);
assert_eq!(editor.context().text, "first\nsecond\n");
editor.handle(Input::Char('p'));
assert_eq!(editor.context().text, "first\nfirst\nsecond\n");
}
+1
View File
@@ -558,6 +558,7 @@ fn diff_repository(fixture: &Fixture, historical: bool) -> (String, Option<Strin
repository(fixture, &["add", "."]); repository(fixture, &["add", "."]);
repository(fixture, &["commit", "-qm", "after"]); repository(fixture, &["commit", "-qm", "after"]);
let after = repository(fixture, &["rev-parse", "HEAD"]); let after = repository(fixture, &["rev-parse", "HEAD"]);
repository(fixture, &["checkout", "--detach", &before]);
fs::write(&fixture.source, "disk is not the historical source\n").unwrap(); fs::write(&fixture.source, "disk is not the historical source\n").unwrap();
fs::write(&fixture.target, "disk is not the historical target\n").unwrap(); fs::write(&fixture.target, "disk is not the historical target\n").unwrap();
after after
+2 -1
View File
@@ -275,6 +275,7 @@ fn readonly_before_and_historical_after_seeks_preserve_snapshots_and_folded_exce
fs::write(&source, &after_text).unwrap(); fs::write(&source, &after_text).unwrap();
fixture.git(&["commit", "-qam", "after"]); fixture.git(&["commit", "-qam", "after"]);
let after = fixture.git(&["rev-parse", "HEAD"]); let after = fixture.git(&["rev-parse", "HEAD"]);
fixture.git(&["checkout", "--detach", &before]);
lines[19] = "working x x tail".into(); lines[19] = "working x x tail".into();
let working_text = lines.join("\n") + "\n"; let working_text = lines.join("\n") + "\n";
fs::write(&source, &working_text).unwrap(); fs::write(&source, &working_text).unwrap();
@@ -386,5 +387,5 @@ fn readonly_before_and_historical_after_seeks_preserve_snapshots_and_folded_exce
assert_eq!(session.editor().cursor(), original_cursor); assert_eq!(session.editor().cursor(), original_cursor);
assert!(session.editor().dirty()); assert!(session.editor().dirty());
assert_eq!(fs::read_to_string(&source).unwrap(), working_text); assert_eq!(fs::read_to_string(&source).unwrap(), working_text);
assert_eq!(fixture.git(&["rev-parse", "HEAD"]), after); assert_eq!(fixture.git(&["rev-parse", "HEAD"]), before);
} }
+706
View File
@@ -0,0 +1,706 @@
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
};
use vamp::editor::{Cursor, Editor, Input, Mode};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"vamp-normal-editing-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed),
));
fs::create_dir_all(&root).unwrap();
Self(root)
}
fn editor(&self, text: &str) -> Editor {
let path = self.0.join("buffer.txt");
fs::write(&path, text).unwrap();
let mut editor = Editor::open(Some(path), vec!["text".into()]).unwrap();
editor.set_clipboard_mode("separate").unwrap();
editor
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn keys(editor: &mut Editor, text: &str) {
for ch in text.chars() {
editor.handle(Input::Char(ch));
}
}
#[test]
fn visual_r_replaces_selected_graphemes_as_one_undo_edit() {
let fixture = Fixture::new();
let source = "ae\u{301}👩‍🚀z\nrest\n";
let mut editor = fixture.editor(source);
keys(&mut editor, "lv2l");
assert_eq!(editor.mode(), Mode::Visual);
keys(&mut editor, "r界");
assert_eq!(editor.context().text, "a界界界\nrest\n");
assert_eq!(editor.mode(), Mode::Normal);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert!(!editor.dirty());
keys(&mut editor, "U");
assert_eq!(editor.context().text, "a界界界\nrest\n");
}
#[test]
fn visual_r_preserves_selected_newlines_in_both_directions_and_shapes() {
for (source, selection, expected) in [
("ae\u{301}👩‍🚀z\n\nbc\nlast\n", "v2j", "QQQQ\n\nQc\nlast\n"),
("ae\u{301}👩‍🚀z\n\nbc\nlast\n", "2jv2k", "QQQQ\n\nQc\nlast\n"),
("ae\u{301}👩‍🚀z\n\nbc\nlast\n", "V2j", "QQQQ\n\nQQ\nlast\n"),
("ab\ncd", "Vj", "QQ\nQQ"),
("\n\n", "Vj", "\n\n"),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, selection);
keys(&mut editor, "rQ");
assert_eq!(editor.context().text, expected, "{selection}");
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.dirty(), source != expected);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
}
}
#[test]
fn visual_r_replaces_every_selection_and_restores_carets_on_undo() {
let fixture = Fixture::new();
let source = "abcd\nwxyz\n";
let mut editor = fixture.editor(source);
keys(&mut editor, "vl");
editor.handle(Input::Ctrl('e'));
let carets = editor.carets();
assert_eq!(editor.selection_count(), 2);
keys(&mut editor, "r界");
assert_eq!(editor.context().text, "界界cd\n界界yz\n");
assert_eq!(editor.selection_count(), 2);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.carets(), carets);
}
#[test]
fn visual_r_repeats_selection_intent_without_overwriting_the_clipboard() {
let fixture = Fixture::new();
let mut editor = fixture.editor("abc\nuvwxyz\n");
keys(&mut editor, "yyvlrQj0.");
assert_eq!(editor.context().text, "QQc\nQQwxyz\n");
keys(&mut editor, "4lv.");
assert_eq!(editor.context().text, "QQc\nQQwxQz\n");
keys(&mut editor, "P");
assert_eq!(editor.context().text, "QQc\nabc\nQQwxQz\n");
}
#[test]
fn cancelled_and_identical_visual_replacements_preserve_redo() {
let fixture = Fixture::new();
let mut editor = fixture.editor("abc\n");
keys(&mut editor, "rQulvr");
editor.handle(Input::Escape);
assert_eq!(editor.mode(), Mode::Visual);
assert_eq!(editor.line_operator_pending(), None);
keys(&mut editor, "rb");
assert_eq!(editor.context().text, "abc\n");
assert_eq!(editor.mode(), Mode::Normal);
assert!(!editor.dirty());
keys(&mut editor, "U");
assert_eq!(editor.context().text, "Qbc\n");
}
#[test]
fn change_and_delete_line_suffix_preserve_newlines_and_group_insert_undo() {
for operation in ["C", "D", "9C", "9D"] {
let fixture = Fixture::new();
let source = "ae\u{301}👩‍🚀tail\nnext\n";
let mut editor = fixture.editor(source);
keys(&mut editor, "2l");
let caret = editor.cursor();
keys(&mut editor, operation);
assert_eq!(editor.context().text, "ae\u{301}\nnext\n", "{operation}");
if operation.ends_with('C') {
assert_eq!(editor.mode(), Mode::Insert);
keys(&mut editor, "replacement");
editor.handle(Input::Escape);
assert_eq!(editor.context().text, "ae\u{301}replacement\nnext\n");
} else {
assert_eq!(editor.mode(), Mode::Normal);
}
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.cursor(), caret);
assert!(!editor.dirty());
}
}
#[test]
fn suffix_commands_at_last_grapheme_and_empty_lines_never_remove_newlines() {
for operation in ["C", "D"] {
for source in ["a👩‍🚀\nnext\n", "a👩‍🚀\nnext"] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, "$");
keys(&mut editor, operation);
assert_eq!(editor.context().text, source.replacen("a👩‍🚀", "a", 1));
if editor.mode() == Mode::Insert {
editor.handle(Input::Escape);
}
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
}
let fixture = Fixture::new();
let mut editor = fixture.editor("\nnext\n");
keys(&mut editor, operation);
assert_eq!(editor.context().text, "\nnext\n");
assert_eq!(
editor.mode(),
if operation == "C" {
Mode::Insert
} else {
Mode::Normal
}
);
assert!(!editor.dirty());
}
}
#[test]
fn yank_line_suffix_is_characterwise_and_does_not_create_an_undo_entry() {
for operation in ["Y", "8Y"] {
let fixture = Fixture::new();
let source = "a👩‍🚀e\u{301}tail\nnext\n";
let mut editor = fixture.editor(source);
keys(&mut editor, "l");
let caret = editor.cursor();
keys(&mut editor, operation);
assert_eq!(editor.context().text, source);
assert_eq!(editor.cursor(), caret);
assert_eq!(editor.mode(), Mode::Normal);
assert!(!editor.dirty());
keys(&mut editor, "j0P");
assert_eq!(
editor.context().text,
"a👩‍🚀e\u{301}tail\n👩‍🚀e\u{301}tailnext\n"
);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert!(!editor.dirty());
}
}
#[test]
fn yank_and_cancelled_replace_preserve_the_previous_repeatable_edit() {
let fixture = Fixture::new();
let mut editor = fixture.editor("abc\nxyz\n");
keys(&mut editor, "xY3r");
editor.handle(Input::Escape);
keys(&mut editor, "j0.");
assert_eq!(editor.context().text, "bc\nyz\n");
keys(&mut editor, "u");
assert_eq!(editor.context().text, "bc\nxyz\n");
keys(&mut editor, "u");
assert_eq!(editor.context().text, "abc\nxyz\n");
}
#[test]
fn replace_consumes_a_literal_character_and_replaces_whole_counted_graphemes() {
for (command, expected) in [
("rx", "ax👩‍🚀z\nnext\n"),
("r2", "a2👩‍🚀z\nnext\n"),
("r界", "a界👩‍🚀z\nnext\n"),
("r\t", "a\t👩‍🚀z\nnext\n"),
("2rQ", "aQQz\nnext\n"),
("9rQ", "aQQQ\nnext\n"),
] {
let fixture = Fixture::new();
let source = "ae\u{301}👩‍🚀z\nnext\n";
let mut editor = fixture.editor(source);
keys(&mut editor, "l");
keys(&mut editor, command);
assert_eq!(editor.context().text, expected, "{command:?}");
assert_eq!(editor.mode(), Mode::Normal);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 });
assert!(!editor.dirty());
}
let fixture = Fixture::new();
let mut editor = fixture.editor("\nnext\n");
keys(&mut editor, "rQ");
assert_eq!(editor.context().text, "\nnext\n");
assert_eq!(editor.mode(), Mode::Normal);
assert!(!editor.dirty());
}
#[test]
fn substitute_deletes_counted_whole_graphemes_and_groups_replacement_with_undo() {
for (command, after_delete, expected) in [
("s", "a👩‍🚀z\nnext\n", "aQ👩‍🚀z\nnext\n"),
("2s", "az\nnext\n", "aQz\nnext\n"),
("9s", "a\nnext\n", "aQ\nnext\n"),
] {
let fixture = Fixture::new();
let source = "ae\u{301}👩‍🚀z\nnext\n";
let mut editor = fixture.editor(source);
keys(&mut editor, "l");
keys(&mut editor, command);
assert_eq!(editor.context().text, after_delete, "{command}");
assert_eq!(editor.mode(), Mode::Insert);
keys(&mut editor, "Q");
editor.handle(Input::Escape);
assert_eq!(editor.context().text, expected);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 });
assert!(!editor.dirty());
}
}
#[test]
fn word_cut_counts_before_and_after_operator_multiply_in_both_directions() {
let source = "one two three four five six seven eight nine\n";
for (position, command, expected) in [
("", "dw", "two three four five six seven eight nine\n"),
("", "d2w", "three four five six seven eight nine\n"),
("", "2dw", "three four five six seven eight nine\n"),
("", "2d3w", "seven eight nine\n"),
("2w", "db", "one three four five six seven eight nine\n"),
("4w", "d2b", "one two five six seven eight nine\n"),
("4w", "2db", "one two five six seven eight nine\n"),
("6w", "2d3b", "seven eight nine\n"),
] {
for operator in ["d", "c"] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, position);
let caret = editor.cursor();
let command = command.replacen('d', operator, 1);
keys(&mut editor, &command);
assert_eq!(editor.context().text, expected, "{position} {command}");
assert_eq!(
editor.mode(),
if operator == "c" {
Mode::Insert
} else {
Mode::Normal
}
);
if operator == "c" {
keys(&mut editor, "Q");
editor.handle(Input::Escape);
}
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.cursor(), caret);
assert!(!editor.dirty());
}
}
}
#[test]
fn word_changes_preserve_line_boundaries_and_group_replacement_with_undo() {
for (source, position, command, after_cut, expected) in [
("one two\nnext\n", "", "cw", "two\nnext\n", "Qtwo\nnext\n"),
("one two\nnext\n", "w", "cb", "two\nnext\n", "Qtwo\nnext\n"),
("one\ntwo\n", "", "c9w", "\ntwo\n", "Q\ntwo\n"),
("one\ntwo\n", "j0", "c9b", "one\ntwo\n", "one\nQtwo\n"),
("one\n\ntwo\n", "j", "cw", "one\n\ntwo\n", "one\nQ\ntwo\n"),
("one\n\ntwo\n", "j", "cb", "one\n\ntwo\n", "one\nQ\ntwo\n"),
("one\n \ntwo\n", "j", "cw", "one\n\ntwo\n", "one\nQ\ntwo\n"),
("one \nnext\n", "", "cw", "\nnext\n", "Q\nnext\n"),
("e\u{301}lan 世界 fin\n", "", "c2w", "fin\n", "Qfin\n"),
("e\u{301}lan 世界", "$", "c9b", "界", "Q界"),
("👩‍🚀!!! next\n", "", "cw", "next\n", "Qnext\n"),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, position);
let caret = editor.cursor();
keys(&mut editor, command);
assert_eq!(editor.context().text, after_cut, "{position} {command}");
assert_eq!(editor.mode(), Mode::Insert);
keys(&mut editor, "Q");
editor.handle(Input::Escape);
assert_eq!(editor.context().text, expected, "{position} {command}");
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.cursor(), caret);
assert!(!editor.dirty());
keys(&mut editor, "U");
assert_eq!(editor.context().text, expected);
}
}
#[test]
fn word_delete_uses_unicode_word_motion_boundaries_across_punctuation() {
for (source, command, expected) in [
("e\u{301}lan 世界 fin\n", "d2w", "fin\n"),
("e\u{301}lan 世界 fin\n", "2wd2b", "fin\n"),
("alpha... beta gamma\n", "d2w", "beta gamma\n"),
("alpha... beta gamma\n", "2wd2b", "beta gamma\n"),
("👩‍🚀!!! next\n", "dw", "next\n"),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, command);
assert_eq!(editor.context().text, expected, "{command} on {source:?}");
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
}
}
#[test]
fn word_delete_stops_at_current_line_boundaries() {
for (source, position, command, expected) in [
("one\ntwo three\n", "", "dw", "\ntwo three\n"),
("one\ntwo three\n", "", "d2w", "\ntwo three\n"),
("one two\nthree four\n", "w", "2d3w", "one \nthree four\n"),
("one\ntwo three\n", "2w", "d2b", "one\nthree\n"),
("one\n two three\n", "jw", "2d3b", "one\ntwo three\n"),
("one \ntwo\n", "", "d9w", "\ntwo\n"),
("one\n \ntwo\n", "j", "dw", "one\n\ntwo\n"),
("one\n\ntwo\n", "j", "d9w", "one\n\ntwo\n"),
("one\n\ntwo\n", "j", "d9b", "one\n\ntwo\n"),
("one\ntwo\n", "j0", "db", "one\ntwo\n"),
("one\n two\n", "jl", "db", "one\n two\n"),
(
"one\ne\u{301}lan 世界\nnext\n",
"j0",
"d9w",
"one\n\nnext\n",
),
(
"one\ne\u{301}lan 世界\nnext\n",
"j$",
"d9b",
"one\n界\nnext\n",
),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, position);
let caret = editor.cursor();
keys(&mut editor, command);
assert_eq!(
editor.context().text,
expected,
"{position} {command} on {source:?}"
);
assert_eq!(editor.mode(), Mode::Normal);
if source == expected {
assert_eq!(editor.cursor(), caret);
assert!(!editor.dirty());
} else {
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.cursor(), caret);
assert!(!editor.dirty());
}
}
}
#[test]
fn word_delete_handles_eof_and_saturating_counts_without_splitting_graphemes() {
let source = "e\u{301}lan 世界";
for (command, expected) in [
("d2w".to_owned(), ""),
(format!("{}d2w", usize::MAX), ""),
("$d3b".to_owned(), "界"),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, &command);
assert_eq!(editor.context().text, expected, "{command}");
assert_eq!(editor.mode(), Mode::Normal);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert!(!editor.dirty());
}
}
#[test]
fn pending_operator_and_replace_cancellation_clear_counts_without_editing() {
for prefix in ["3d", "d2", "2d3", "3c", "c2", "2c3", "3r"] {
let fixture = Fixture::new();
let source = "abcd\nnext\n";
let mut editor = fixture.editor(source);
keys(&mut editor, prefix);
editor.handle(Input::Escape);
assert_eq!(editor.context().text, source, "{prefix}");
assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 });
assert_eq!(editor.mode(), Mode::Normal);
assert!(!editor.dirty());
keys(&mut editor, "x");
assert_eq!(editor.context().text, "bcd\nnext\n", "{prefix}");
}
for input in [
Input::Enter,
Input::Char('\n'),
Input::Paste("ignored".into()),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor("abcd\nnext\n");
keys(&mut editor, "3r");
editor.handle(input);
keys(&mut editor, "lx");
assert_eq!(editor.context().text, "acd\nnext\n");
assert_eq!(editor.mode(), Mode::Normal);
}
}
#[test]
fn new_edit_recipes_repeat_against_the_current_text_and_undo_as_single_changes() {
for (source, first, after_first, position, expected) in [
(
"prefix one\nlongprefix two\n",
"7lCnew",
"prefix new\nlongprefix two\n",
"j011l",
"prefix new\nlongprefix new\n",
),
(
"prefix one\nlongprefix two\n",
"7lD",
"prefix \nlongprefix two\n",
"j011l",
"prefix \nlongprefix \n",
),
(
"e\u{301}abc\n👩‍🚀xyz\n",
"rQ",
"Qabc\n👩‍🚀xyz\n",
"j0",
"Qabc\nQxyz\n",
),
(
"e\u{301}abc\n👩‍🚀xyz\n",
"sQ",
"Qabc\n👩‍🚀xyz\n",
"j0",
"Qabc\nQxyz\n",
),
(
"one two keep\nthree four stay\n",
"d2w",
"keep\nthree four stay\n",
"j0",
"keep\nstay\n",
),
(
"one two keep\nthree four stay\n",
"2wd2b",
"keep\nthree four stay\n",
"j02w",
"keep\nstay\n",
),
("one\nthree four\n", "d9w", "\nthree four\n", "j0", "\n\n"),
(
"one keep\nthree four stay\n",
"wd9b",
"keep\nthree four stay\n",
"j02w",
"keep\nstay\n",
),
(
"one two keep\nthree four stay\n",
"c2wQ",
"Qkeep\nthree four stay\n",
"j0",
"Qkeep\nQstay\n",
),
(
"one two keep\nthree four stay\n",
"2wc2bQ",
"Qkeep\nthree four stay\n",
"j02w",
"Qkeep\nQstay\n",
),
(
"one\nthree four\n",
"c9wQ",
"Q\nthree four\n",
"j0",
"Q\nQ\n",
),
(
"one keep\nthree four stay\n",
"wc9bQ",
"Qkeep\nthree four stay\n",
"j02w",
"Qkeep\nQstay\n",
),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, first);
if editor.mode() == Mode::Insert {
editor.handle(Input::Escape);
}
assert_eq!(editor.context().text, after_first, "{first}");
keys(&mut editor, position);
keys(&mut editor, ".");
assert_eq!(editor.context().text, expected, "{first} then {position}.");
assert_eq!(editor.mode(), Mode::Normal);
keys(&mut editor, "u");
assert_eq!(editor.context().text, after_first);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert!(!editor.dirty());
}
}
#[test]
fn dd_and_cc_keep_linewise_behavior_with_counts_after_the_operator() {
let source = "1\n2\n3\n4\n5\n6\n7\n8\n9\n";
for (command, remaining) in [
("dd", "2\n3\n4\n5\n6\n7\n8\n9\n"),
("2dd", "3\n4\n5\n6\n7\n8\n9\n"),
("d2d", "3\n4\n5\n6\n7\n8\n9\n"),
("2d3d", "7\n8\n9\n"),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, command);
assert_eq!(editor.context().text, remaining, "{command}");
keys(&mut editor, "P");
assert_eq!(editor.context().text, source, "{command} linewise paste");
}
for (command, remaining) in [
("cc", "2\n3\n4\n5\n6\n7\n8\n9\n"),
("2cc", "3\n4\n5\n6\n7\n8\n9\n"),
("c2c", "3\n4\n5\n6\n7\n8\n9\n"),
("2c3c", "7\n8\n9\n"),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, command);
assert_eq!(editor.mode(), Mode::Insert);
assert_eq!(editor.context().text, format!("\n{remaining}"), "{command}");
keys(&mut editor, "Q");
editor.handle(Input::Escape);
assert_eq!(editor.context().text, format!("Q\n{remaining}"));
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert!(!editor.dirty());
}
}
#[test]
fn native_multicarets_edit_whole_graphemes_and_yank_matching_suffix_fragments() {
let source = "a👩‍🚀tail\nbe\u{301}rest\n";
for (command, expected) in [
("D", "a\nb\n"),
("CQ", "aQ\nbQ\n"),
("rQ", "aQtail\nbQrest\n"),
("sQ", "aQtail\nbQrest\n"),
("YP", "a👩‍🚀tail👩‍🚀tail\nbe\u{301}reste\u{301}rest\n"),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, "l");
editor.handle(Input::Ctrl('e'));
let carets = editor.carets();
assert_eq!(carets.len(), 2);
keys(&mut editor, command);
if editor.mode() == Mode::Insert {
editor.handle(Input::Escape);
}
assert_eq!(editor.context().text, expected, "{command}");
assert_eq!(editor.selection_count(), 2);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.carets(), carets);
}
for (command, expected) in [
("d2w", "keep\nstay\n"),
("2wd2b", "keep\nstay\n"),
("d9w", "\n\n"),
("2wd9b", "keep\nstay\n"),
("c2wQ", "Qkeep\nQstay\n"),
("2wc2bQ", "Qkeep\nQstay\n"),
("c9wQ", "Q\nQ\n"),
("2wc9bQ", "Qkeep\nQstay\n"),
] {
let fixture = Fixture::new();
let source = "one two keep\nthree four stay\n";
let mut editor = fixture.editor(source);
editor.handle(Input::Ctrl('e'));
keys(&mut editor, command);
if editor.mode() == Mode::Insert {
editor.handle(Input::Escape);
}
assert_eq!(editor.context().text, expected, "{command}");
assert_eq!(editor.selection_count(), 2);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.selection_count(), 2);
}
}
#[test]
fn remapped_core_edit_actions_keep_counts_pending_input_and_clipboard_semantics() {
for (action, original_key, position, command, expected) in [
("core.change_to_line_end", "C", "l", "qX", "aX\nnext\n"),
("core.delete_to_line_end", "D", "l", "q", "a\nnext\n"),
(
"core.copy_to_line_end",
"Y",
"l",
"qj0P",
"abc def ghi\nbc def ghinext\n",
),
(
"core.replace_character",
"r",
"l",
"qZ",
"aZc def ghi\nnext\n",
),
(
"core.substitute_character",
"s",
"l",
"qZ",
"aZc def ghi\nnext\n",
),
("core.delete_word_forward", "d", "", "2q", "ghi\nnext\n"),
("core.delete_word_backward", "d", "2w", "2q", "ghi\nnext\n"),
("core.change_word_forward", "c", "", "2qQ", "Qghi\nnext\n"),
(
"core.change_word_backward",
"c",
"2w",
"2qQ",
"Qghi\nnext\n",
),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor("abc def ghi\nnext\n");
editor.bind("normal", original_key, None).unwrap();
editor.bind("normal", "q", Some(action.into())).unwrap();
keys(&mut editor, position);
keys(&mut editor, command);
if editor.mode() == Mode::Insert {
editor.handle(Input::Escape);
}
assert_eq!(editor.context().text, expected, "{action}");
assert_eq!(editor.mode(), Mode::Normal);
}
}
+229
View File
@@ -0,0 +1,229 @@
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
};
use vamp::editor::{Cursor, Editor, Input, Mode};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"vamp-paste-shapes-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed),
));
fs::create_dir_all(&root).unwrap();
Self(root)
}
fn editor(&self, text: &str) -> Editor {
let path = self.0.join("buffer.txt");
fs::write(&path, text).unwrap();
let mut editor = Editor::open(Some(path), vec!["text".into()]).unwrap();
editor.set_clipboard_mode("separate").unwrap();
editor
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn keys(editor: &mut Editor, text: &str) {
for ch in text.chars() {
editor.handle(Input::Char(ch));
}
}
#[test]
fn whole_line_yank_pastes_above_or_below_the_target_and_keeps_indentation() {
let source = " \t界e\u{301}👩‍🚀\nother\nlast\n";
for (paste, expected, row) in [
('p', " \t界e\u{301}👩‍🚀\nother\n \t界e\u{301}👩‍🚀\nlast\n", 2),
('P', " \t界e\u{301}👩‍🚀\n \t界e\u{301}👩‍🚀\nother\nlast\n", 1),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, "3l");
let yank_caret = editor.cursor();
keys(&mut editor, "yy");
assert_eq!(editor.context().text, source);
assert_eq!(editor.cursor(), yank_caret);
assert_eq!(editor.mode(), Mode::Normal);
assert!(!editor.dirty());
keys(&mut editor, "j2l");
let paste_caret = editor.cursor();
editor.handle(Input::Char(paste));
assert_eq!(editor.context().text, expected, "{paste}");
assert_eq!(editor.cursor(), Cursor { row, column: 2 });
assert_eq!(editor.mode(), Mode::Normal);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.cursor(), paste_caret);
assert!(!editor.dirty());
}
}
#[test]
fn whole_line_paste_handles_unterminated_rows_empty_rows_and_the_final_empty_row() {
for (source, position, paste, expected, row) in [
("top\n last", "j2l", 'p', "top\n last\n last", 2),
("top\n last", "j2l", 'P', "top\n last\n last", 1),
("top\n last\n", "j2l", 'p', "top\n last\n last\n", 2),
("top\n last\n", "j2l", 'P', "top\n last\n last\n", 1),
("top\n\nend", "j", 'p', "top\n\n\nend", 2),
("top\n\nend", "j", 'P', "top\n\n\nend", 1),
("top\n", "j", 'p', "top\n\n", 2),
("top\n", "j", 'P', "top\n\n", 1),
("", "", 'p', "\n", 1),
("", "", 'P', "\n", 0),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, position);
let caret = editor.cursor();
keys(&mut editor, "yy");
editor.handle(Input::Char(paste));
assert_eq!(
editor.context().text,
expected,
"{source:?} {position}yy{paste}"
);
assert_eq!(
editor.cursor(),
Cursor {
row,
column: caret.column
}
);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.cursor(), caret);
assert!(!editor.dirty());
}
}
#[test]
fn counted_line_yanks_multiply_prefixes_and_clamp_at_the_last_row() {
let source = "one\ntwo\nthree\nfour\nfive\nsix\nseven";
for (command, expected) in [
("2yy", "one\none\ntwo\ntwo\nthree\nfour\nfive\nsix\nseven"),
("y2y", "one\none\ntwo\ntwo\nthree\nfour\nfive\nsix\nseven"),
(
"2y3y",
"one\none\ntwo\nthree\nfour\nfive\nsix\ntwo\nthree\nfour\nfive\nsix\nseven",
),
(
"9yy",
"one\none\ntwo\nthree\nfour\nfive\nsix\nseven\ntwo\nthree\nfour\nfive\nsix\nseven",
),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, command);
assert_eq!(editor.context().text, source, "{command}");
keys(&mut editor, "p");
assert_eq!(editor.context().text, expected, "{command}p");
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert!(!editor.dirty());
}
}
#[test]
fn visual_character_yank_pastes_before_or_after_whole_extended_graphemes() {
let source = "ae\u{301}👩‍🚀z\nx👨‍👩‍👧‍👦q\n";
for (paste, expected, column) in [
('p', "ae\u{301}👩‍🚀z\nx👨‍👩‍👧‍👦e\u{301}👩‍🚀q\n", 3),
('P', "ae\u{301}👩‍🚀z\nxe\u{301}👩‍🚀👨‍👩‍👧‍👦q\n", 2),
] {
let fixture = Fixture::new();
let mut editor = fixture.editor(source);
keys(&mut editor, "lvly");
assert_eq!(editor.context().text, source);
assert!(!editor.dirty());
keys(&mut editor, "j0l");
let caret = editor.cursor();
editor.handle(Input::Char(paste));
assert_eq!(editor.context().text, expected, "{paste}");
assert_eq!(editor.cursor(), Cursor { row: 1, column });
assert_eq!(editor.mode(), Mode::Normal);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.cursor(), caret);
editor.handle(Input::Char(paste));
assert_eq!(
editor.context().text,
expected,
"register survives undo and paste"
);
}
}
#[test]
fn dot_keeps_the_pasted_line_shape_without_replacing_the_new_yank_register() {
let fixture = Fixture::new();
let source = " a\nb\nc";
let mut editor = fixture.editor(source);
keys(&mut editor, "yyp");
assert_eq!(editor.context().text, " a\n a\nb\nc");
keys(&mut editor, "jyy.");
assert_eq!(editor.context().text, " a\n a\nb\n a\nc");
keys(&mut editor, "u");
assert_eq!(editor.context().text, " a\n a\nb\nc");
keys(&mut editor, "p");
assert_eq!(editor.context().text, " a\n a\nb\nb\nc");
keys(&mut editor, "u");
assert_eq!(editor.context().text, " a\n a\nb\nc");
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert!(!editor.dirty());
}
#[test]
fn native_multicarets_yank_matching_whole_lines_and_paste_in_one_undo_step() {
let fixture = Fixture::new();
let source = " a\n\tb\nc";
let mut editor = fixture.editor(source);
keys(&mut editor, "l");
editor.handle(Input::Ctrl('e'));
let carets = editor.carets();
assert_eq!(carets.len(), 2);
keys(&mut editor, "yy");
assert_eq!(editor.carets(), carets);
keys(&mut editor, "p");
assert_eq!(editor.context().text, " a\n a\n\tb\n\tb\nc");
assert_eq!(
editor.carets(),
vec![Cursor { row: 1, column: 1 }, Cursor { row: 3, column: 1 }],
);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert_eq!(editor.carets(), carets);
assert!(!editor.dirty());
keys(&mut editor, "P");
assert_eq!(editor.context().text, " a\n a\n\tb\n\tb\nc");
}
#[test]
fn a_visual_yank_ending_in_a_newline_stays_characterwise() {
let fixture = Fixture::new();
let source = "\nxy";
let mut editor = fixture.editor(source);
keys(&mut editor, "vyj0p");
assert_eq!(editor.context().text, "\nx\ny");
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
keys(&mut editor, "P");
assert_eq!(editor.context().text, "\n\nxy");
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
assert!(!editor.dirty());
}
+724 -6
View File
@@ -16,7 +16,7 @@ use std::{
}, },
}; };
use vamp::{ use vamp::{
diff::{Category, Comparison, FileStatus, RepoKind, Revision, RevisionTarget}, diff::{Category, Comparison, ComparisonMode, FileStatus, RepoKind, Revision, RevisionTarget},
repository::Repository, repository::Repository,
}; };
@@ -134,7 +134,8 @@ fn compare_loaded(
after: &Revision, after: &Revision,
buffers: &[(PathBuf, String)], buffers: &[(PathBuf, String)],
) -> Result<Comparison, String> { ) -> Result<Comparison, String> {
let mut comparison = repository.compare(1, before, after, buffers)?; let mut comparison =
repository.compare(1, before, after, ComparisonMode::Snapshots, buffers)?;
for (index, file) in comparison.files.iter_mut().enumerate() { for (index, file) in comparison.files.iter_mut().enumerate() {
*file = repository.load_file(1, index)?; *file = repository.load_file(1, index)?;
if file if file
@@ -374,14 +375,23 @@ fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_
&[], &[],
) )
.unwrap(); .unwrap();
assert!(reverse.editable_paths.is_empty()); assert!(reverse.editable_paths.contains(Path::new("text")));
assert!(!reverse.editable_paths.contains(Path::new("added")));
assert!(!reverse.editable_paths.contains(Path::new("link")));
assert_eq!(changed(&reverse, Path::new("text")).after, ["one", "three"]);
assert_eq!( assert_eq!(
changed(&reverse, Path::new("added")).status, changed(&reverse, Path::new("added")).status,
FileStatus::Removed FileStatus::Removed
); );
assert!( assert!(
repository repository
.compare(1, &working(RepoKind::Git), &working(RepoKind::Git), &[]) .compare(
1,
&working(RepoKind::Git),
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[]
)
.unwrap() .unwrap()
.files .files
.is_empty() .is_empty()
@@ -509,6 +519,46 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st
.success() .success()
); );
assert_eq!(disk_state(&active), state); assert_eq!(disk_state(&active), state);
// An advertised remote alias already available at HEAD is eligible without
// importing refs or substituting the local unsaved overlay for pinned bytes.
git(
&source,
&["remote", "add", "origin", bare.to_str().unwrap()],
);
let source_state = disk_state(&source);
let mut checked_out = open(&source);
let advertised = checked_out.list_remote("origin").unwrap();
let alias = advertised
.entries
.iter()
.find(|entry| entry.target == remote.target)
.unwrap();
let pinned = checked_out
.compare(
20,
&revision(&ancestor, RepoKind::Git),
alias,
ComparisonMode::Snapshots,
&[(source.join("file"), "dirty live\n".into())],
)
.unwrap();
assert!(pinned.editable_paths.contains(Path::new("file")));
assert_eq!(checked_out.load_file(20, 0).unwrap().after, ["remote tip"]);
assert_eq!(disk_state(&source), source_state);
git(&source, &["checkout", "--detach", &ancestor]);
let readonly = checked_out
.refresh_checkout(20, 21, false, &[])
.unwrap()
.unwrap();
assert!(readonly.editable_paths.is_empty());
assert_eq!(checked_out.load_file(21, 0).unwrap().after, ["remote tip"]);
git(&source, &["checkout", "--detach", &tip]);
let promoted = checked_out
.refresh_checkout(21, 22, false, &[])
.unwrap()
.unwrap();
assert_eq!(promoted.editable_paths, pinned.editable_paths);
assert_eq!(checked_out.load_file(22, 0).unwrap().after, ["remote tip"]);
} }
#[test] #[test]
@@ -574,6 +624,40 @@ fn jj_catalog_and_working_comparisons_prefer_jj_and_never_snapshot_or_merge_oper
.iter() .iter()
.any(|f| f.path == Path::new("ignored-untracked")) .any(|f| f.path == Path::new("ignored-untracked"))
); );
let synthetic_root = revision(&"0".repeat(40), RepoKind::Jujutsu);
let branch = repository
.compare(
20,
&synthetic_root,
baseline,
ComparisonMode::BranchChanges,
&[],
)
.unwrap();
assert!(branch.before.ends_with(&"0".repeat(40)));
assert!(
branch
.files
.iter()
.any(|file| file.path == Path::new("file"))
);
let reversed = repository
.compare(
21,
baseline,
&synthetic_root,
ComparisonMode::BranchChanges,
&[],
)
.unwrap();
assert!(reversed.files.is_empty());
assert!(
repository
.compare(22, baseline, baseline, ComparisonMode::BranchChanges, &[])
.unwrap()
.files
.is_empty()
);
assert_eq!(disk_state(&active), state); assert_eq!(disk_state(&active), state);
} }
} }
@@ -657,6 +741,16 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
.iter() .iter()
.any(|r| r.target == RevisionTarget::Commit(ancestor.clone())) .any(|r| r.target == RevisionTarget::Commit(ancestor.clone()))
); );
let local = catalog.entries.iter().find(|revision| {
matches!(&revision.target, RevisionTarget::Commit(id) if id.bytes().any(|byte| byte != b'0'))
}).unwrap();
let branch = repository
.compare(60, local, &remote, ComparisonMode::BranchChanges, &[])
.unwrap();
assert!(branch.before.ends_with(&"0".repeat(40)));
assert_eq!(branch.files.len(), 1);
assert_eq!(repository.load_file(60, 0).unwrap().after, ["remote tip"]);
assert!(branch.editable_paths.is_empty());
let comparison = let comparison =
compare_loaded(&mut repository, &remote, &working(RepoKind::Jujutsu), &[]).unwrap(); compare_loaded(&mut repository, &remote, &working(RepoKind::Jujutsu), &[]).unwrap();
assert_eq!( assert_eq!(
@@ -682,6 +776,14 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
changed(&comparison, Path::new("file")).after, changed(&comparison, Path::new("file")).after,
["remote tip"] ["remote tip"]
); );
let branch = repository
.compare(61, historic, &remote, ComparisonMode::BranchChanges, &[])
.unwrap();
assert!(branch.before.ends_with(&ancestor));
assert_eq!(
repository.load_file(61, 0).unwrap().before,
["remote ancestor"]
);
let store = active.join(".jj/repo/store"); let store = active.join(".jj/repo/store");
let active_store = let active_store =
fs::canonicalize(store.join(fs::read_to_string(store.join("git_target")).unwrap().trim())) fs::canonicalize(store.join(fs::read_to_string(store.join("git_target")).unwrap().trim()))
@@ -702,6 +804,31 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
.success() .success()
); );
assert_eq!(disk_state(&active), state); assert_eq!(disk_state(&active), state);
assert!(
repository
.refresh_checkout(61, 62, false, &[])
.unwrap()
.is_none()
);
// Simulate importing and checking out the privately selected endpoint behind
// the viewer. Its initially unavailable active-jj identity must be retried.
jj(&active, &["git", "fetch", "--remote", "origin"]);
jj(&active, &["edit", "--ignore-immutable", &tip]);
let checked_out_state = disk_state(&active);
let promoted = repository
.refresh_checkout(
61,
62,
false,
&[(active.join("file"), "dirty live\n".into())],
)
.unwrap()
.unwrap();
assert_eq!(promoted.before, branch.before);
assert_eq!(promoted.after, branch.after);
assert!(promoted.editable_paths.contains(Path::new("file")));
assert_eq!(repository.load_file(62, 0).unwrap().after, ["remote tip"]);
assert_eq!(disk_state(&active), checked_out_state);
} }
#[test] #[test]
@@ -747,6 +874,7 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
1, 1,
&revision("--bad", RepoKind::Git), &revision("--bad", RepoKind::Git),
&working(RepoKind::Git), &working(RepoKind::Git),
ComparisonMode::Snapshots,
&[] &[]
) )
.unwrap_err() .unwrap_err()
@@ -758,6 +886,7 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
1, 1,
&revision(&"f".repeat(40), RepoKind::Git), &revision(&"f".repeat(40), RepoKind::Git),
&working(RepoKind::Git), &working(RepoKind::Git),
ComparisonMode::Snapshots,
&[], &[],
) )
.unwrap_err() .unwrap_err()
@@ -769,7 +898,13 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
}; };
assert!( assert!(
repository repository
.compare(1, &loader, &revision(&id, RepoKind::Git), &[]) .compare(
1,
&loader,
&revision(&id, RepoKind::Git),
ComparisonMode::Snapshots,
&[]
)
.unwrap_err() .unwrap_err()
.contains("history loader") .contains("history loader")
); );
@@ -813,6 +948,7 @@ fn unchanged_submodules_are_not_false_changes_and_changed_pointers_are_explicit(
1, 1,
&revision(&baseline, RepoKind::Git), &revision(&baseline, RepoKind::Git),
&working(RepoKind::Git), &working(RepoKind::Git),
ComparisonMode::Snapshots,
&[] &[]
) )
.unwrap() .unwrap()
@@ -1033,6 +1169,29 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable
assert_eq!(ordinary.before, ["old"]); assert_eq!(ordinary.before, ["old"]);
assert_eq!(ordinary.after, ["left ordinary"]); assert_eq!(ordinary.after, ["left ordinary"]);
assert!(ordinary.notice.is_none()); assert!(ordinary.notice.is_none());
let branch = repository
.compare(
30,
&revision(&base, RepoKind::Jujutsu),
&revision(&merged, RepoKind::Jujutsu),
ComparisonMode::BranchChanges,
&[],
)
.unwrap();
let index = branch
.files
.iter()
.position(|file| file.path == Path::new("conflict"))
.unwrap();
let conflict = repository.load_file(30, index).unwrap();
assert_eq!(conflict.after, expected);
assert!(
conflict
.notice
.as_deref()
.unwrap()
.contains("conflict projection")
);
let working_comparison = compare_loaded( let working_comparison = compare_loaded(
&mut repository, &mut repository,
&revision(&merged, RepoKind::Jujutsu), &revision(&merged, RepoKind::Jujutsu),
@@ -1112,7 +1271,7 @@ fn unsaved_overlays_are_working_after_only_and_cannot_follow_nonregular_paths()
); );
assert!( assert!(
repository repository
.compare(1, &base, &base, &buffers) .compare(1, &base, &base, ComparisonMode::Snapshots, &buffers)
.unwrap() .unwrap()
.files .files
.is_empty() .is_empty()
@@ -1152,6 +1311,7 @@ fn immutable_catalog_skips_unchanged_blobs_and_prepares_only_requested_details()
100, 100,
&revision(&before, RepoKind::Git), &revision(&before, RepoKind::Git),
&revision(&after, RepoKind::Git), &revision(&after, RepoKind::Git),
ComparisonMode::Snapshots,
&[], &[],
) )
.unwrap(); .unwrap();
@@ -1176,6 +1336,7 @@ fn immutable_catalog_skips_unchanged_blobs_and_prepares_only_requested_details()
101, 101,
&revision(&after, RepoKind::Git), &revision(&after, RepoKind::Git),
&revision(&after, RepoKind::Git), &revision(&after, RepoKind::Git),
ComparisonMode::Snapshots,
&[], &[],
) )
.unwrap(); .unwrap();
@@ -1198,6 +1359,7 @@ fn deferred_file_details_keep_frozen_checkout_and_unsaved_after() {
50, 50,
&working(RepoKind::Git), &working(RepoKind::Git),
&working(RepoKind::Git), &working(RepoKind::Git),
ComparisonMode::Snapshots,
&[(root.join("file"), "unsaved after\n".into())], &[(root.join("file"), "unsaved after\n".into())],
) )
.unwrap(); .unwrap();
@@ -1211,3 +1373,559 @@ fn deferred_file_details_keep_frozen_checkout_and_unsaved_after() {
assert_eq!(again.before, details.before); assert_eq!(again.before, details.before);
assert_eq!(again.after, details.after); assert_eq!(again.after, details.after);
} }
#[test]
fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_text() {
let fixture = Fixture::new();
let root = fixture.dir("follow").canonicalize().unwrap();
init_git(&root);
fs::write(root.join("file"), "base\n").unwrap();
fs::write(root.join("removed"), "remove me\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "base"]);
let base = git(&root, &["rev-parse", "HEAD"]);
fs::write(root.join("file"), "original disk\n").unwrap();
let mut repository = open(&root);
let original = compare_loaded(
&mut repository,
&revision(&base, RepoKind::Git),
&working(RepoKind::Git),
&[],
)
.unwrap();
fs::write(root.join("file"), "later disk\n").unwrap();
assert!(
repository
.refresh_checkout(1, 2, true, &[])
.unwrap()
.is_none()
);
assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]);
fs::remove_file(root.join("removed")).unwrap();
fs::write(root.join("added"), "new file\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "descendant"]);
let tip = git(&root, &["rev-parse", "HEAD"]);
let state = disk_state(&root);
assert!(
repository
.refresh_checkout(99, 2, true, &[])
.unwrap()
.is_none()
);
assert!(
repository
.refresh_checkout(1, 2, false, &[])
.unwrap()
.is_none()
);
assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]);
let mut refreshed = repository
.refresh_checkout(1, 2, true, &[(root.join("file"), "unsaved\n".into())])
.unwrap()
.unwrap();
assert_eq!(refreshed.before, original.before);
assert_eq!(refreshed.after, original.after);
for (index, file) in refreshed.files.iter_mut().enumerate() {
*file = repository.load_file(2, index).unwrap();
}
assert_eq!(changed(&refreshed, Path::new("file")).before, ["base"]);
assert_eq!(changed(&refreshed, Path::new("file")).after, ["unsaved"]);
assert_eq!(
changed(&refreshed, Path::new("added")).status,
FileStatus::Added
);
assert_eq!(
changed(&refreshed, Path::new("removed")).status,
FileStatus::Removed
);
assert!(refreshed.editable_paths.contains(Path::new("file")));
assert!(refreshed.editable_paths.contains(Path::new("added")));
assert!(!refreshed.editable_paths.contains(Path::new("removed")));
assert!(repository.load_file(1, 0).is_err());
assert!(
repository
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
assert_eq!(disk_state(&root), state);
git(&root, &["checkout", "--detach", &base]);
assert!(
repository
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
git(&root, &["checkout", "-b", "other"]);
fs::write(root.join("file"), "other branch\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "unrelated to tip"]);
assert!(
repository
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
let index = refreshed
.files
.iter()
.position(|file| file.path == Path::new("file"))
.unwrap();
assert_eq!(repository.load_file(2, index).unwrap().after, ["unsaved"]);
git(&root, &["checkout", "--detach", &tip]);
assert!(
repository
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
}
#[test]
fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure() {
let fixture = Fixture::new();
let root = fixture.dir("frozen-follow").canonicalize().unwrap();
init_git(&root);
fs::write(root.join("file"), "base\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "base"]);
let base = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git);
fs::write(root.join("file"), "frozen before\n").unwrap();
let cancelled = Arc::new(AtomicBool::new(false));
let mut repository = Repository::open(&root, cancelled.clone()).unwrap();
repository
.compare(
10,
&working(RepoKind::Git),
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[(root.join("file"), "frozen after\n".into())],
)
.unwrap();
fs::write(root.join("file"), "next checkout\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "next"]);
cancelled.store(true, Ordering::Release);
assert!(repository.refresh_checkout(10, 11, true, &[]).is_err());
cancelled.store(false, Ordering::Release);
assert_eq!(repository.load_file(10, 0).unwrap().after, ["frozen after"]);
fs::write(root.join("oversized"), vec![b'x'; 17 * 1024 * 1024]).unwrap();
assert!(repository.refresh_checkout(10, 11, true, &[]).is_err());
let original = repository.load_file(10, 0).unwrap();
assert_eq!(original.before, ["frozen before"]);
assert_eq!(original.after, ["frozen after"]);
assert!(repository.load_file(11, 0).is_err());
fs::remove_file(root.join("oversized")).unwrap();
let refreshed = repository
.refresh_checkout(10, 11, true, &[])
.unwrap()
.unwrap();
assert_eq!(refreshed.files.len(), 1);
let file = repository.load_file(11, 0).unwrap();
assert_eq!(file.before, ["frozen before"]);
assert_eq!(file.after, ["next checkout"]);
let tip = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git);
let pinned = repository
.compare(12, &base, &tip, ComparisonMode::Snapshots, &[])
.unwrap();
assert!(pinned.editable_paths.contains(Path::new("file")));
fs::write(root.join("file"), "newer checkout\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "newer"]);
assert!(
repository
.refresh_checkout(99, 13, false, &[])
.unwrap()
.is_none()
);
let readonly = repository
.refresh_checkout(12, 13, false, &[(root.join("file"), "dirty live\n".into())])
.unwrap()
.unwrap();
assert_eq!(readonly.before, pinned.before);
assert_eq!(readonly.after, pinned.after);
assert!(readonly.editable_paths.is_empty());
assert_eq!(readonly.files.len(), pinned.files.len());
assert_eq!(readonly.files[0].path, pinned.files[0].path);
assert!(repository.load_file(12, 0).is_err());
assert_eq!(
repository.load_file(13, 0).unwrap().after,
["next checkout"]
);
assert!(
repository
.refresh_checkout(13, 14, false, &[])
.unwrap()
.is_none()
);
let RevisionTarget::Commit(tip_id) = &tip.target else {
unreachable!()
};
git(&root, &["checkout", "--detach", tip_id]);
let promoted = repository
.refresh_checkout(13, 14, false, &[])
.unwrap()
.unwrap();
assert_eq!(promoted.editable_paths, pinned.editable_paths);
fs::write(root.join("file"), "dirty disk\n").unwrap();
assert_eq!(
repository.load_file(14, 0).unwrap().after,
["next checkout"]
);
cancelled.store(true, Ordering::Release);
assert!(repository.refresh_checkout(14, 15, false, &[]).is_err());
cancelled.store(false, Ordering::Release);
let recovered = repository
.refresh_checkout(14, 15, false, &[])
.unwrap()
.unwrap();
assert_eq!(recovered.editable_paths, pinned.editable_paths);
assert_eq!(
repository.load_file(15, 0).unwrap().after,
["next checkout"]
);
}
#[test]
fn refresh_materialization_failure_restores_prepared_file_sources() {
let fixture = Fixture::new();
let root = fixture.dir("failed-materialization");
init_git(&root);
fs::write(root.join("a-file"), "frozen source\n").unwrap();
fs::write(root.join("z-large"), vec![b'x'; 17 * 1024 * 1024]).unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "base"]);
let base = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git);
fs::remove_file(root.join("a-file")).unwrap();
fs::remove_file(root.join("z-large")).unwrap();
let mut repository = open(&root);
let original = repository
.compare(
1,
&base,
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
let index = original
.files
.iter()
.position(|file| file.path == Path::new("a-file"))
.unwrap();
fs::write(root.join("a-file"), "new checkout\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "descendant"]);
assert!(repository.refresh_checkout(1, 2, true, &[]).is_err());
let file = repository.load_file(1, index).unwrap();
assert_eq!(file.before, ["frozen source"]);
assert!(file.after.is_empty());
assert_eq!(file.status, FileStatus::Removed);
assert!(repository.load_file(2, index).is_err());
}
#[test]
fn unborn_git_working_after_safely_advances_to_its_first_commit() {
let fixture = Fixture::new();
let root = fixture.dir("unborn");
init_git(&root);
let mut repository = open(&root);
repository
.compare(
1,
&working(RepoKind::Git),
&working(RepoKind::Git),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert!(
repository
.refresh_checkout(1, 2, true, &[])
.unwrap()
.is_none()
);
fs::write(root.join("first"), "initial\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "initial"]);
let comparison = repository
.refresh_checkout(1, 2, true, &[])
.unwrap()
.unwrap();
assert_eq!(comparison.files.len(), 1);
assert_eq!(repository.load_file(2, 0).unwrap().after, ["initial"]);
}
#[test]
fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
for colocate in [false, true] {
let fixture = Fixture::new();
let root = fixture.dir("jj-follow").canonicalize().unwrap();
init_jj(&root, colocate);
fs::write(root.join("file"), "base\n").unwrap();
fs::write(root.join("removed"), "remove me\n").unwrap();
jj(&root, &["describe", "-m", "base"]);
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new"]);
fs::write(root.join("file"), "original disk\n").unwrap();
let mut repository = open(&root);
compare_loaded(
&mut repository,
&revision(&base, RepoKind::Jujutsu),
&working(RepoKind::Jujutsu),
&[],
)
.unwrap();
fs::write(root.join("file"), "same-change rewrite\n").unwrap();
jj(&root, &["describe", "-m", "rewritten"]);
let state = disk_state(&root);
assert!(
repository
.refresh_checkout(1, 2, true, &[])
.unwrap()
.is_none()
);
assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]);
assert_eq!(disk_state(&root), state);
jj(&root, &["new"]);
fs::remove_file(root.join("removed")).unwrap();
fs::write(root.join("added"), "new file\n").unwrap();
let state = disk_state(&root);
let mut comparison = repository
.refresh_checkout(1, 2, true, &[(root.join("file"), "unsaved\n".into())])
.unwrap()
.unwrap();
for (index, file) in comparison.files.iter_mut().enumerate() {
*file = repository.load_file(2, index).unwrap();
}
assert_eq!(changed(&comparison, Path::new("file")).before, ["base"]);
assert_eq!(changed(&comparison, Path::new("file")).after, ["unsaved"]);
assert_eq!(
changed(&comparison, Path::new("added")).status,
FileStatus::Added
);
assert_eq!(
changed(&comparison, Path::new("removed")).status,
FileStatus::Removed
);
assert!(repository.load_file(1, 0).is_err());
assert_eq!(disk_state(&root), state);
assert!(
repository
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
jj(&root, &["edit", &base]);
assert!(
repository
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
jj(&root, &["new", &base]);
assert!(
repository
.refresh_checkout(2, 3, true, &[])
.unwrap()
.is_none()
);
let index = comparison
.files
.iter()
.position(|file| file.path == Path::new("file"))
.unwrap();
assert_eq!(repository.load_file(2, index).unwrap().after, ["unsaved"]);
repository
.compare(
10,
&working(RepoKind::Jujutsu),
&working(RepoKind::Jujutsu),
ComparisonMode::Snapshots,
&[(root.join("file"), "frozen after\n".into())],
)
.unwrap();
fs::write(root.join("file"), "new checkout\n").unwrap();
jj(&root, &["new"]);
let refreshed = repository
.refresh_checkout(10, 11, true, &[])
.unwrap()
.unwrap();
assert_eq!(refreshed.files.len(), 1);
let file = repository.load_file(11, 0).unwrap();
assert_eq!(file.before, ["base"]);
assert_eq!(file.after, ["new checkout"]);
let historical = revision(&base, RepoKind::Jujutsu);
repository
.compare(12, &historical, &historical, ComparisonMode::Snapshots, &[])
.unwrap();
jj(&root, &["new"]);
assert!(
repository
.refresh_checkout(12, 13, true, &[])
.unwrap()
.is_none()
);
let tip_id = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
let pinned_tip = revision(&tip_id, RepoKind::Jujutsu);
let pinned = repository
.compare(20, &historical, &pinned_tip, ComparisonMode::Snapshots, &[])
.unwrap();
assert!(pinned.editable_paths.contains(Path::new("file")));
let index = pinned
.files
.iter()
.position(|file| file.path == Path::new("file"))
.unwrap();
assert_eq!(
repository.load_file(20, index).unwrap().after,
["new checkout"]
);
fs::write(root.join("file"), "rewritten checkout\n").unwrap();
jj(&root, &["describe", "-m", "same pinned change"]);
let rewritten = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
assert_ne!(rewritten, tip_id);
assert!(
repository
.refresh_checkout(20, 21, false, &[])
.unwrap()
.is_none()
);
assert_eq!(
repository.load_file(20, index).unwrap().after,
["new checkout"]
);
let same_change = repository
.compare(21, &historical, &pinned_tip, ComparisonMode::Snapshots, &[])
.unwrap();
assert_eq!(same_change.editable_paths, pinned.editable_paths);
jj(&root, &["new"]);
let readonly = repository
.refresh_checkout(21, 22, false, &[(root.join("file"), "dirty live\n".into())])
.unwrap()
.unwrap();
assert!(readonly.editable_paths.is_empty());
assert_eq!(readonly.before, pinned.before);
assert_eq!(readonly.after, pinned.after);
assert_eq!(readonly.files.len(), pinned.files.len());
assert_eq!(
repository.load_file(22, index).unwrap().after,
["new checkout"]
);
jj(&root, &["edit", &rewritten]);
let promoted = repository
.refresh_checkout(22, 23, false, &[])
.unwrap()
.unwrap();
assert_eq!(promoted.editable_paths, pinned.editable_paths);
assert_eq!(
repository.load_file(23, index).unwrap().after,
["new checkout"]
);
}
}
#[test]
fn branch_changes_resolves_local_and_private_remote_ancestry_without_active_mutation() {
let fixture = Fixture::new();
let active = fixture.dir("active\nquoted\"\\");
init_git(&active);
fs::write(active.join("common"), "base\n").unwrap();
git(&active, &["add", "."]);
git(&active, &["commit", "-m", "base"]);
let base = git(&active, &["rev-parse", "HEAD"]);
let source = fixture.0.join("source");
git(
&fixture.0,
&["clone", active.to_str().unwrap(), source.to_str().unwrap()],
);
git(&source, &["config", "user.name", "Diff Test"]);
git(&source, &["config", "user.email", "diff@example.invalid"]);
fs::write(source.join("remote-only"), "remote\n").unwrap();
git(&source, &["add", "."]);
git(&source, &["commit", "-m", "remote"]);
fs::write(active.join("local-only"), "local\n").unwrap();
git(&active, &["add", "."]);
git(&active, &["commit", "-m", "local"]);
let local = revision(&git(&active, &["rev-parse", "HEAD"]), RepoKind::Git);
git(
&active,
&["remote", "add", "origin", source.to_str().unwrap()],
);
let state = disk_state(&active);
let mut repository = open(&active);
let catalog = repository.list_remote("origin").unwrap();
let remote = catalog
.entries
.iter()
.find(|r| matches!(r.target, RevisionTarget::Remote { .. }))
.unwrap();
let branch = repository
.compare(40, &local, remote, ComparisonMode::BranchChanges, &[])
.unwrap();
assert_eq!(branch.files.len(), 1);
assert_eq!(branch.files[0].path, Path::new("remote-only"));
assert!(branch.before.ends_with(&base));
assert_eq!(repository.load_file(40, 0).unwrap().after, ["remote"]);
let reverse = repository
.compare(41, remote, &local, ComparisonMode::BranchChanges, &[])
.unwrap();
assert_eq!(reverse.files.len(), 1);
assert_eq!(reverse.files[0].path, Path::new("local-only"));
assert_eq!(repository.load_file(41, 0).unwrap().after, ["local"]);
assert!(
repository
.compare(42, &local, &local, ComparisonMode::BranchChanges, &[])
.unwrap()
.files
.is_empty()
);
assert_eq!(disk_state(&active), state);
}
#[test]
fn branch_changes_rejects_missing_and_multiple_merge_bases_without_snapshot_fallback() {
let fixture = Fixture::new();
let root = fixture.dir("graphs");
init_git(&root);
fs::write(root.join("file"), "base\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "base"]);
let base = git(&root, &["rev-parse", "HEAD"]);
let tree = git(&root, &["rev-parse", "HEAD^{tree}"]);
let left = git(&root, &["commit-tree", &tree, "-p", &base, "-m", "left"]);
let right = git(&root, &["commit-tree", &tree, "-p", &base, "-m", "right"]);
let one = git(
&root,
&["commit-tree", &tree, "-p", &left, "-p", &right, "-m", "one"],
);
let two = git(
&root,
&["commit-tree", &tree, "-p", &right, "-p", &left, "-m", "two"],
);
let unrelated = git(&root, &["commit-tree", &tree, "-m", "unrelated"]);
let state = disk_state(&root);
let mut repository = open(&root);
for (before, after, message) in [
(&base, &unrelated, "no merge base"),
(&one, &two, "multiple merge bases"),
] {
let before = revision(before, RepoKind::Git);
let after = revision(after, RepoKind::Git);
assert!(
repository
.compare(50, &before, &after, ComparisonMode::BranchChanges, &[])
.unwrap_err()
.contains(message)
);
assert!(
repository
.compare(51, &before, &after, ComparisonMode::Snapshots, &[])
.unwrap()
.files
.is_empty()
);
}
assert_eq!(disk_state(&root), state);
}