Compare commits

...

16 Commits

Author SHA1 Message Date
Valdemar Lorenzen ef4c38d8a7 feat: resolve conflicts through focused two-way drafts
Replace the marker-filled three-pane resolver with a marker-free native
Draft versus frozen Alternative projected from current working text.
Preserve preexisting outside edits, native undo, dirty-save guards, all
sides/bases, and draft caret/viewports across separate History views.
Reuse DiffState alignment/highlighting/excerpts for Resolution and merge
its base-aligned rows for readonly three-way History. Hide explorer and
source-selector chrome by default.

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

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

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

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

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

Verified all 905 tests and the original local bookmark comparison with
25 correct paths in 0.522s, plus actual terminal conflict viewing.
Strict Clippy is blocked by existing diagnostics outside this change.
2026-10-14 15:49:26 +02:00
Valdemar Lorenzen f26237af78 perf: batch queued cursor navigation events 2026-10-14 15:32:52 +02:00
Valdemar Lorenzen 1cb9a0ddfc fix: retain repository status between picker openings 2026-10-14 14:56:55 +02:00
Valdemar Lorenzen 77a9111f45 fix: align diff picker review checkmarks 2026-10-14 14:36:15 +02:00
Valdemar Lorenzen 6b27fab075 fix: read macOS clipboard without subprocess startup 2026-10-14 14:26:22 +02:00
Valdemar Lorenzen 0000089f42 feat: stream newest pull requests by category 2026-10-14 14:26:22 +02:00
Valdemar Lorenzen 5243de66fe fix: resolve SSH aliases for forge discovery 2026-10-14 13:51:49 +02:00
Valdemar Lorenzen af6496bd5a feat: browse GitHub GitLab and Gitea pull requests 2026-10-14 13:40:43 +02:00
Valdemar Lorenzen 15968a328c fix: preserve diff backgrounds and align file markers 2026-10-14 13:40:41 +02:00
Valdemar Lorenzen 8a1e98819c fix: support mutable language tab configuration 2026-10-14 13:13:01 +02:00
Valdemar Lorenzen c5f80f27a6 fix: replace selected ranges instead of ignoring visual paste 2026-10-14 13:13:01 +02:00
Valdemar Lorenzen 5580d6fbd4 refactor: share language IDs with indentation configuration 2026-10-14 13:12:50 +02:00
Valdemar Lorenzen 6a43fb9e0c feat: configure spaces on Tab per language 2026-10-14 13:12:46 +02:00
29 changed files with 9274 additions and 415 deletions
+2
View File
@@ -34,11 +34,13 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal.
| Search in Normal | `/`, type a literal query, Enter; `n`/`N` for next/previous |
| 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 over selected text | `p` / `P` in Visual replaces every selection, preserving the clipboard |
| Command palette | Ctrl+p; type to filter, arrows select, Enter accepts, Esc cancels |
| 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` |
| 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 |
| Resolve jj working-copy conflicts | `:resolve`; edit marker-free Draft versus Alternative, `:resolve.history` for inputs/base, `:w` to save and confirm with jj |
Space is the default leader: press its suffix keys in order, not simultaneously. The [editing guide](docs/editing.md) contains the complete key table and behavior reference.
+5 -13
View File
@@ -1,16 +1,8 @@
- [x] add option to highlight trailing whitespace (off by default)
- [x] add option to put characters at certain whitespace characters (off by default)
- whitespace kind -> character map should be configurable.
- [ ] add option to insert spaces instead tab character when pressing tab:
- number of spaces should be configurable (per language)
- [ ] diff view - design question:
- is it possible to angle the hatchings for empty lines more?
- if a character like the current one exists with a 45degree angle instead (and a top and bottom), we could use that across two columns. that would give us a perfectly 45 degree hatching.
- [ ] github/gitlab/gitea integration (probably requries authentication with github)
- github pr picker (floating window)
- three tabs: open, closed, all
- search under the given category
- 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)
- [x] jj conflict resolution with ui akin to the diff viewer
- [ ] make selecting a file in the diff view file picker focus the after buffer instead of the before buffer
- [ ] improve the lsp hover floating window:
- it should be taller
- [ ] add `*` on word to highlights and searches in buffer in one action.
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
- [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding.
- design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files.
+5 -1
View File
@@ -78,19 +78,23 @@ 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.
The resolver owns a dedicated native tab with an editable Draft and frozen read-only Alternatives projected from the current raw working buffer. Projection replaces only conflict regions; automatically merged text and pre-existing outside edits remain identical across initial alternatives. Native undo includes initial seeding. Resolution reuses DiffState alignment, excerpts, highlighting and viewport mapping, including equal-text editing. A separate read-only History uses the original jj inputs/bases; native buffer identity and frontend offsets retain draft/view state. `resolve` reads all alternating Git-backed `jj:trees` terms. Background guarded saves retain checkout/disk validation and jj snapshot/status authority; no presentation change bypasses these write guards.
`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.
Already-ready mouse reports share a redraw: at most 64 reports are applied in their original order before painting, without dropping or summing away wheel steps. Readiness checks do not wait. The first non-mouse event is retained for the next iteration after rendering, so keys, paste, and resizes cannot reuse geometry from before their layout changes. The batch bound keeps continuous mouse input from starving drawing or background polling.
Already-ready Insert typing and directly bound Normal/Visual document motions also share a redraw, with the same 64-key bound and non-waiting readiness checks. The editor classifies motion bindings, including remaps and comparison text panes, so the TUI does not hard-code navigation keys. Each key still passes through session dispatch separately, preserving counts, intermediate column clamping, Visual ranges, and history. Mode changes end a batch; pending key/operator/seek prefixes, focused lists/popups, non-motion bindings, release reports, and non-key events remain redraw boundaries. No repeat timer or acceleration is added.
Ratatui's bounded layout cache is enabled: unchanged geometry and constraints reuse solved rectangles during scrolling; resizes and panel-layout changes use their own cache keys.
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.
The same cancellable worker collects normal-picker status for the startup project root, including when line-gutter rendering is disabled and no picker is open. The editor retains the last completed project-wide result across picker closure and shares it with ordinary pickers in every tab; new pickers apply it before their first render. Opening or closing a picker does not restart status work. Normal file rows reserve a fixed marker column even before the first result or when the checkout is clean. Disk metadata and dirty-buffer overlays use Git HEAD or jj's single parent without snapshotting jj or writing active refs, objects, or indexes. A private baseline index retains its stat cache across polls; conflicted jj parents are projected once per root/parent. Root, baseline, store, or builtin Git conversion-setting changes invalidate that cache. Private queries disable user filters, external diff, and textconv. Request/root checks reject stale picker results; unchanged catalogs retain their ordinary scanner and filtering rules.
## Syntax caches
+22 -2
View File
@@ -19,6 +19,8 @@ Vamp.configure do |config|
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.tab_spaces = nil # opt in with a fixed number of spaces per Tab, e.g. 4
# config.language_tab_spaces.merge!("ruby" => 2, "makefile" => nil)
config.file_explorer_side = "right"
config.file_explorer_view = "tree"
config.file_picker_view = "tree"
@@ -67,6 +69,23 @@ Whitespace inside strings recognized by the bundled syntax grammar stays literal
Markers appear in ordinary and historical buffers and both sides of split/unified diffs. Tabs and wide Unicode whitespace show the marker in their first source cell and leave remaining cells blank, preserving tab stops, wrapping, horizontal scrolling, and cursor/selection geometry. A clipped first cell hides its marker; padding beyond the source does not gain markers. Whitespace with combining characters remains intact. Syntax colors, search, selections, diagnostics, and trailing-whitespace highlighting still apply. This changes display only: editing, copying, and saving retain the original characters.
`config.tab_spaces` defaults to `nil`, preserving literal tabs. Set an integer from `1` through `255` to insert that many spaces per Tab in Insert mode, at every caret. This is a fixed count, not alignment to the next tab stop; it does not change how existing tab characters are displayed.
`config.language_tab_spaces` defaults to `{}`. It overrides the global setting using the same case-sensitive language IDs as `config.lsp`, for example `{ "rust" => 4, "ruby" => 2, "python" => 4, "makefile" => nil }`. Syntax detection and built-in LSP presets share canonical IDs rather than maintaining separate naming conventions; other bundled IDs include `shellscript`, `cpp`, and `typescriptreact` for Bash, C++, and TSX. Display labels such as “Ruby” remain presentation-only. A `nil` override preserves literal tabs for that language, even when the global setting uses spaces. Unlisted or unknown languages and unnamed buffers without a recognized shebang use `tab_spaces`. Detection reuses syntax highlighting's filename, extension, and shebang rules, independently of whether an LSP is enabled.
Like `config.lsp`, `config.language_tab_spaces` exposes a mutable hash: use `merge!` to add or override entries, or `config.language_tab_spaces["ruby"] = 2` to change one entry. Whole-hash assignment with `=` remains supported. Both assignment and the final hash contents are validated; invalid keys or widths inserted through hash mutation reject startup configuration rather than bypassing validation.
```ruby
config.tab_spaces = 4
config.language_tab_spaces.merge!(
"rust" => 4,
"ruby" => 2,
"makefile" => nil,
)
```
Active snippets retain Tab/Shift+Tab argument navigation; after the final stop, Tab uses the configured insertion setting. Explicit Tab bindings still take precedence, and Ctrl+i remains a Tab alias. Bracketed paste, clipboard paste, completion payloads, and plugin text retain literal tab characters. Insert undo/redo and dot repeat preserve the resolved spaces or tab. Widths other than `nil` or integers in `1..255`, non-hash language overrides, and empty, non-string, or control-containing language keys reject the configuration.
`config.git_blame` defaults to `true`; set it to `false` to disable current-line inline Git blame and its background worker. Only literal Ruby booleans are accepted; other values reject the configuration. Toggle it for the running session with `:git_blame.toggle`, its palette action, or a custom binding such as `config.bind "leader b", config.core.git_blame.toggle`. There is no default shortcut.
`config.git_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.
@@ -204,6 +223,7 @@ Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through
| Normal | `follow_link` |
| Normal | `diff`, `diff.mode.toggle`, `diff.view.toggle`, `diff.orientation.toggle`, `diff.review.toggle` |
| Normal, Insert, Visual, Command, Search | `diff.close` |
| Normal | `resolve`, `resolve.next`, `resolve.previous`, `resolve.use_source`, `resolve.source`, `resolve.base`, `resolve.next_file`, `resolve.previous_file`, `resolve.files`, `resolve.draft`, `resolve.history`, `resolve.view.toggle`, `resolve.close` |
| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` |
| Normal, Insert, Visual, Command, Search | `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` |
@@ -211,8 +231,8 @@ Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through
| Normal | `jump_back`, `jump_forward` |
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
| Insert | `lsp.completion`, `snippet.next`, `snippet.previous` |
| Normal | `search`, `paste_before`, `paste_after` |
| Normal | `paste_system_before`, `paste_system_after` |
| Normal | `search` |
| Normal, Visual | `paste_before`, `paste_after`, `paste_system_before`, `paste_system_after` |
| Normal, Visual | `select`, `select_linewise`, `search_next`, `search_previous` |
| Normal, Visual | `repeat_last_edit` |
| Normal, Visual | `file_picker`, `file_explorer` |
+5
View File
@@ -47,6 +47,7 @@ Rust tests live alongside their modules; native LSP integration checks are in [`
- Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover raw/alternate-screen/mouse-capture cleanup on entry failure, normal return, I/O errors, and panics during frame construction, including original panic-payload propagation.
- Redraw checks inspect actual Crossterm output during repeated scrolling: supporting terminals receive synchronized frames, terminals ignoring synchronization never paint with a visible hardware cursor, and the final caret remains visible. A protocol-state regression verifies that the terminal is not left in synchronized-output mode while the next frame is computed.
- Wheel checks render document/panel views and cover three-row scrolling, unchanged carets/selections/text, first/last-page clamping, offscreen cursor hiding, no-op/custom-bound navigation reveal, manual persistence through idle/resize/palette updates, and floating/unfocused-docked explorer selection and focus preservation. Queued-input checks verify that the next frame shows ordered wheel steps without crossing a key or resize boundary.
- Queued-navigation checks cover counted ordered motions through short rows, Normal/Visual ranges, remapped press/repeat keys, rendered caret visibility, edit/layout/prefix and non-key barriers, and the 64-key fairness bound. Burst smoke must verify the final cursor position as well as frame count; batching reduces queued redraws without increasing the OS/terminal repeat rate.
- Native LSP integration checks launch framed-protocol fixture subprocesses, covering initialization/settings, opt-in startup, synchronization, UTF-16/file-URI conversion, dirty targets, stale requests/diagnostics, and transport failures. Stack checks cover independent initialization, diagnostic merging/clearing, sibling failure isolation in active/parked buffers, same-revision rename/close identity, aggregate limits, first-server position routing, and both shutdowns. Semantic ordering uses protocol/file gates; elapsed deadlines only bound stuck subprocesses.
- Hover regression checks cover content forms, sanitization, cancelled/superseded replies, invalid results, scrolling/dismissal, Unicode clipping, and preserved editing state. Rendered checks cover longer/wider documentation visibility, Markdown heading and fenced-code colors, scrolled color coordinates, and replacement content without stale highlighting.
- Semantic-token checks cover full/range requests, legend/modifier remapping, UTF-16 boundaries and grapheme rounding, ASCII tab and Unicode column bounds, refresh/change coalescing, stale rename/close replies, null/empty/error fallback, primary/sibling retirement, and rendered cell precedence with syntax, diagnostics, search, selections, and carets.
@@ -55,6 +56,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.
Conflicted-revision regressions preserve unchanged symlinks, defer oversized unselected blobs until file selection, compare selected conflict text with jj's projection, and exclude unchanged conflicts when comparing later revisions. Live terminal smoke covers local bookmark selection and opening an actual conflicted file without changing the checkout.
Resolver checks use isolated real jj repositories: read-only discovery, marker-free all-side/base projections preserving outside edits and EOF, initial seed undo, hunk choice/undo, retained resolved files, jj-authoritative status, checkout/external-write refusal, cancellation, nontext bytes/types/modes/deletion, and directory-child protection. Render checks cover aligned gaps/highlights, context folding, native Draft focus/search/selection, separate History, hidden/on-demand explorer, viewport retention and narrow layouts. Live PTY smoke exercises frozen marker-free alternatives, History/Draft switching, hunk choice and guarded save confirmation without touching user repositories.
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.
+60 -8
View File
@@ -44,6 +44,7 @@ Indicators refresh asynchronously. Typing and undo/redo retain the last complete
| Normal | `v` starts characterwise Visual; `V` starts linewise Visual; `p`/`P` paste the configured clipboard after/before each caret or row for linewise fragments (system by default); Esc keeps only the primary caret |
| Normal, Visual | Ctrl+k clones above; Ctrl+e clones below |
| Visual | Cursor/word motions and `n`/`N` extend every selection; `y` copies; `d`/`x`/Delete cuts; `c` changes; `v`/`V` switches shape, the same selector or Esc cancels |
| Visual | `p` / `P` replaces each selection with the configured clipboard, without overwriting it |
| Visual | Ctrl+l splits touched rows into Normal carets at first nonblank; Ctrl+n adds the next occurrence; Ctrl+s skips the primary occurrence; Ctrl+a selects all occurrences |
| Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End at every caret outside snippets; Esc returns to Normal, retaining carets |
| Insert | Ctrl+n requests completion at the primary caret or cycles next; Ctrl+j/k cycles next/previous while completion is open |
@@ -53,6 +54,8 @@ Indicators refresh asynchronously. Typing and undo/redo retain the last complete
| Insert, Command, Search | Ctrl+u deletes backward to the logical line start |
| Insert, Command, Search (macOS) | Option+Backspace deletes backward by word; Command+Backspace deletes backward to the logical line start |
Outside active snippets, Insert-mode Tab inserts a literal tab by default. Set [`config.tab_spaces` and `config.language_tab_spaces`](configuration.md#preferences) to insert a fixed number of spaces, with per-language overrides. The setting applies to every caret and editable diff text; existing tabs and pasted text are not converted.
`J` removes the next line's leading spaces/tabs and preserves existing trailing whitespace. It normally adds one space, two after `.`, `?`, or `!` (Vim's default), and none before `)` or when either side is empty. The cursor lands at the join point; the join is one undo action. On the last line, `J` does nothing.
`gt`/`gb` move every caret to the first nonblank grapheme on the first/last row, including the empty final row after a trailing newline. Duplicate carets and overlapping Visual selections normalize as usual. Both Visual shapes retain their anchors and extend to the destination. These motions do not edit text or add undo history; count prefixes are consumed rather than interpreted as line numbers. `g` waits for its second key, and Escape cancels only the pending prefix. In Insert and command/search prompts, `gt`/`gb` remain literal text.
@@ -65,11 +68,11 @@ The terminal may deliver a native modifier or a conventional control sequence: O
With an enabled server that provides completion, Ctrl+n in Insert requests candidates at the **primary caret only**. The first candidate is inserted immediately and a small list appears near the caret; Ctrl+n or Ctrl+j cycles forward, Ctrl+k backward, with wraparound. There is no acceptance step. The **Original text** row restores the document and carets from before the result was shown, including any additional edits. Completion never broadcasts a candidate to secondary carets: they are preserved and translated through each chosen edit.
Every candidate is applied from that same immutable original document, not on top of the previous candidate. A candidate's primary replacement and additional text edits are one atomic change. Other keys close the list without accepting it: plain-text completion retains ordinary typing, newline, and literal Tab behavior; snippet completion keeps its argument navigation described below. Escape returns to Normal retaining the displayed text. There is no automatic popup. Empty, unsupported, or invalid results leave the document unchanged and report a message.
Every candidate is applied from that same immutable original document, not on top of the previous candidate. A candidate's primary replacement and additional text edits are one atomic change. Other keys close the list without accepting it: plain-text completion retains ordinary typing, newline, and configured Tab insertion; snippet completion keeps its argument navigation described below. Escape returns to Normal retaining the displayed text. There is no automatic popup. Empty, unsupported, or invalid results leave the document unchanged and report a message.
Cycling creates no separate undo entries. Only the final chosen edits join the existing Insert undo group; choosing Original text adds no completion edit. Undo restores that group, and dot repeats the final resolved completion edits rather than requests or intermediate candidates. The list stays inside the document text workspace, does not cover the gutter or explorer, and never takes the hardware cursor. Wheel input over it is consumed without choosing a candidate.
Snippet candidates expand the full template immediately, including parentheses and argument defaults. The first numbered argument is highlighted; typing replaces it, then continues within that argument. Repeated occurrences of the same argument update together, without broadcasting to secondary carets. Backspace/Delete stay within the active argument; Enter can insert a newline there. **Tab** moves forward and **Shift+Tab** backward in ascending tabstop order. Replacing an enclosing argument removes its nested arguments, not adjacent independent ones. Reaching the final `$0` position ends navigation, so subsequent Tab inserts a literal tab. Escape, ordinary cursor motion, paste, commands, and focus/buffer changes also end navigation without removing the expanded text.
Snippet candidates expand the full template immediately, including parentheses and argument defaults. The first numbered argument is highlighted; typing replaces it, then continues within that argument. Repeated occurrences of the same argument update together, without broadcasting to secondary carets. Backspace/Delete stay within the active argument; Enter can insert a newline there. **Tab** moves forward and **Shift+Tab** backward in ascending tabstop order. Replacing an enclosing argument removes its nested arguments, not adjacent independent ones. Reaching the final `$0` position ends navigation, so subsequent Tab uses the configured spaces-or-tab insertion. Escape, ordinary cursor motion, paste, commands, and focus/buffer changes also end navigation without removing the expanded text.
For example, `call(${1:argument}, ${2:count})$0` previews `call(argument, count)`. Type `value`, Tab, `3`, Tab to produce `call(value, 3)` with the caret after the closing parenthesis. Argument edits remain in the same Insert undo group, and dot repeats the resolved text and final caret rather than re-expanding placeholders.
@@ -125,6 +128,8 @@ Ctrl+k/Ctrl+e clone each target to the adjacent row, preserving preferred termin
Copy and cut return to Normal at each selection start; Visual cancellation keeps each active endpoint and removes highlighting. Normal Esc collapses to the primary. Characterwise `p`/`P` paste after/before each current grapheme, including multiline text. Linewise clipboard fragments paste complete rows below/above each target row, including at end of file. Internal fragments retain their shape; system clipboard shape is retained only while its bytes match the editor's last publication, and unrelated external clipboard text is characterwise. Characterwise paste lands on its last inserted grapheme, or the new row's start when its fragment ends in a newline; linewise paste lands at the first nonblank of the first inserted row. Every multi-target edit is planned against the original buffer and committed as one undo action, not one per target. Copy, movement, and cancellation do not add history or clear redo. Undo/redo restore text, the full caret set, and primary identity in Normal mode, not Visual highlighting or the clipboard.
In Visual and Visual Line, both `p` and `P` replace each complete selected range rather than inserting beside its caret. Clipboard fragments are inserted literally, including their newlines: a characterwise fragment replacing a whole-line selection does not gain a newline, and a linewise fragment replacing characters keeps its newline. The edit returns to Normal, is one undo group across all selections, and supports dot repeat with the captured clipboard and selection intent. Neither clipboard is overwritten by the replaced text. Failed clipboard reads, invalid text, empty clipboard, or mismatched fragment counts leave the selection and document intact.
Visual `c` removes all selected text into the configured clipboard and enters Insert at each selection start. In the default System policy, clipboard publication must succeed before any deletion; failures leave text and selections intact. Deletion and replacement typing form one undo action when Insert mode is exited, including through explicit command execution. Esc without typing commits the deletion alone. An identical replacement creates no history entry and preserves redo. Changing only characterwise empty final-row selections enters Insert without overwriting either clipboard; linewise empty final rows are genuine rows and publish a newline.
Visual `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.
@@ -135,9 +140,9 @@ Clipboard policy is session-wide and defaults to the system clipboard. Copy/cut
## Clipboard policy
`config.clipboard_mode = "system"` is the default: ordinary Visual `y`/`d`/`x`/`c` and Normal `p`/`P` use the OS clipboard. External clipboard changes are read on each paste. Set `"separate"` to keep ordinary operations on vamp's internal fragment register instead. Both registers remain independent across buffers; changing the policy does not overwrite either.
`config.clipboard_mode = "system"` is the default: ordinary Visual `y`/`d`/`x`/`c` and Normal/Visual `p`/`P` use the OS clipboard. External clipboard changes are read on each paste. Set `"separate"` to keep ordinary operations on vamp's internal fragment register instead. Both registers remain independent across buffers; changing the policy does not overwrite either.
Explicit `config.core.copy_system` (Visual) and `config.core.paste_system_before` / `paste_system_after` (Normal) always use the system clipboard in either policy. They appear in the command palette and can be bound without replacing ordinary internal operations:
Explicit `config.core.copy_system` (Visual) and `config.core.paste_system_before` / `paste_system_after` (Normal/Visual) always use the system clipboard in either policy. They appear in the command palette and can be bound without replacing ordinary internal operations:
```ruby
config.clipboard_mode = "separate"
@@ -149,7 +154,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.
macOS copies publish UTF-8 plain text directly through the native pasteboard API, avoiding interpreter/process startup for every copy or cut; paste reads still use `osascript` with JXA/AppKit. Linux Wayland uses `wl-paste`/`wl-copy`, and Linux X11 uses `xclip`. Linux tools must be on PATH with the corresponding display environment. Other platforms report unsupported access; choose Separate mode when system clipboard access is unavailable.
macOS copies publish UTF-8 plain text through the native pasteboard API, and pastes read text directly through AppKit, avoiding interpreter/process startup for every copy, cut, or paste. Linux Wayland uses `wl-paste`/`wl-copy`, and Linux X11 uses `xclip`. Linux tools must be on PATH with the corresponding display environment. Other platforms report unsupported access; choose Separate mode when system clipboard access is unavailable.
## Repeat last edit
@@ -314,7 +319,7 @@ The picker includes ordinary buffers from every tab and unnamed buffers as `[No
Space o opens a floating **Tree** explorer directly in **INSERT/filter** mode, ready for typing; Space e opens or focuses the docked **Tree**, starting in **NORMAL/navigation** mode when new. Both use one catalog and instance. Invoking the other action applies that presentation's configured default while retaining query, selection identities, and surviving folder expansion. Floating Tree invocation enters filtering if not already filtering. These actions are available as `config.core.file_picker` and `config.core.file_explorer` in Normal/Visual and in the command palette.
Normal pickers and explorers show the same colored **A/M/D/R** markers as the diff file picker: green additions, yellow modifications, red deletions, and cyan renames (`old -> new`). Unchanged files remain visible. Status compares disk and unsaved buffer text with Git `HEAD` or Git-backed jj's single parent `@-`; it works in both Tree and List, even with `git_gutter = "none"`. Missing tracked files remain as deleted entries; opening one uses the usual missing-file behavior. Status refreshes on opening, edits/saves, focus regain, and once per second. Ctrl+r rescans the normal catalog and refreshes status; status polling alone does not rescan for newly created files.
Normal pickers and explorers show the same colored **A/M/D/R** markers as the diff file picker: green additions, yellow modifications, red deletions, and cyan renames (`old -> new`). Unchanged files remain visible. Status compares disk and unsaved buffer text with Git `HEAD` or Git-backed jj's single parent `@-`; it works in both Tree and List, even with `git_gutter = "none"`. Missing tracked files remain as deleted entries; opening one uses the usual missing-file behavior. Status loads asynchronously from startup and refreshes on edits/saves, focus regain, and once per second, even while the picker is closed. Opening or reopening a picker uses the latest completed project-wide result without restarting the status query. The marker column always reserves its width, so filenames do not shift while the initial result arrives or when the checkout becomes clean. Ctrl+r rescans the normal catalog and refreshes status; status polling alone does not rescan for newly created files.
While a docked Tree has focus in **NORMAL/navigation** mode, leader sequences work in ordinary and diff views: Space o replaces the dock with the floating picker, and Space e returns focus to the document like Escape while leaving the dock visible. Invoking Space e again from the document refocuses it. Tree/selection navigation overrides retain priority. In **INSERT/filter** mode and List query fields, printable leader keys remain literal query text rather than triggering editor actions; configured control-key action bindings remain available.
@@ -371,8 +376,26 @@ Printable input belongs to the focused query, including `:`, `_`, and `|`; Norma
The selector initially lists local revisions and known tracking refs **without contacting remotes**. **Load refs from origin** (or another configured remote) in Bookmarks/Branches/Tags lists advertised references, including refs never fetched locally, without advancing the selection stage. Selecting a reference such as `some-bookmark@origin` reuses available content or fetches the missing selected reference into a temporary comparison repository. **Load history from origin** in Revisions/Commits explicitly imports searchable remote history. Network operations use the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout.
**Pull requests:** run **`:pull_requests [remote]`**, use the palette's `pull_requests` action, or bind **`config.core.pull_requests`**. The floating picker has **Open / Closed / All** tabs; Closed includes merged requests. Tab/Shift+Tab changes category, typing fuzzy-searches its number, title, author, state, and branches, and Enter immediately opens the selected request in **Branch changes** mode using its immutable head and merge base. Escape restores the source buffer. A failed list can be retried with Enter. The default remote is `origin`, or the sole configured remote; specify a name when there are multiple remotes without `origin`.
GitHub, GitLab merge requests, and Gitea are supported. API reads require `curl`; public repositories work anonymously. Authentication uses `GH_TOKEN`/`GITHUB_TOKEN` for github.com, `GH_ENTERPRISE_TOKEN`/`GITHUB_ENTERPRISE_TOKEN` for GitHub Enterprise, `GITLAB_TOKEN` for GitLab, and `GITEA_TOKEN` for Gitea. With no matching environment token, GitHub/GitLab reuse `gh auth token --hostname HOST` / `glab auth token --hostname HOST` when available. Tokens are supplied to curl on stdin, not in subprocess arguments, and sent only to the HTTPS API; redirects are not followed. Git object fetching uses the configured remote's existing Git/SSH credentials separately.
Standard GitHub/GitLab/Gitea hosts and Codeberg are detected from HTTPS or SSH remote URLs. For another host, set `remote.<remote>.vamp-forge` to `github`, `gitlab`, or `gitea` in the repository's Git configuration, for example `git config remote.origin.vamp-forge gitea`. For a non-colocated jj repository, use `git --git-dir .jj/repo/store/git config remote.origin.vamp-forge gitea`.
SSH aliases are resolved with `ssh -G` before provider detection and API authentication, honoring the effective `HostName` and user-specific `Match` settings. SSH ports do not become HTTPS API ports. Git fetches retain the original remote URL, alias, and SSH identity configuration. A failed API load shows its error without also claiming that the search has no matches; the empty-result message appears only after a successful load.
Open and Closed fetch their first pages concurrently, newest-first, and display each page immediately. Subsequent pages load in the background, with the active category dispatched first; loaded rows remain selectable. Pages request 50 entries. GitLab Closed combines separate closed and merged requests (up to 100 entries per batch). All merges the two catalogs without a third fetch stream, deduplicates requests, and sorts by creation time; background updates preserve the selected PR's identity.
Search filters loaded entries locally and explicitly indicates partial results while more pages remain. A later-page error retains loaded rows; **Ctrl+r** retries failed categories (the active category, or both from All). Enter selects a loaded row; in an empty failed category it retries loading. Escape cancels outstanding work. Selection stops listing and only then retrieves that PR's historical metadata and preserved head reference, so GitLab detail reads and Gitea ref lookups do not delay the catalog.
Fork heads are fetched through the target repository's PR refs into the existing private comparison store, without changing active refs, jj operations, or checkout files. Closed/merged requests retain historical bases and heads rather than comparing against today's branch tips. If the server no longer retains the required commits, opening the request reports an error instead of displaying a different diff. Review submission and comments are separate, not part of this picker.
Missing or unprepared historical metadata is reported when selecting the affected request; it does not block listing other requests or substitute a live branch tip.
Comparison discovery builds the changed-path/status catalog first. File text, notices, and hunks load asynchronously when a file is selected and remain cached for that comparison. Failed file loads can be retried by selecting the file again; cancelled or superseded results cannot replace the current selection. Working-checkout comparisons still capture working bytes and the historical content needed to identify changes, preserving the frozen Before snapshot and unsaved After buffers.
Conflicted jj revisions also discover files without reading every blob. Ordinary files and symbolic links retain their Git contents; selected conflicted files show jj's rendered conflict projection. Unchanged links and jj's internal merge-tree metadata do not appear as changes. Viewing a conflict does not resolve it or modify the revision.
**Working checkout After follows forward checkouts automatically**, checked asynchronously once per second, including inactive tabs. A descendant jj change or Git HEAD rebuilds the changed-file catalog and captures current working bytes plus eligible unsaved buffers, while keeping the original Before frozen. jj tracking uses change IDs, so snapshot/description rewrites of the same change do not advance the endpoint or prevent following its later descendants. Same, backward, and unrelated checkouts do not rebuild the catalog; live-buffer editing and external reload still work normally. Historical After never follows. Set `config.diff_follow_working_copy = false` to disable following.
**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.
@@ -399,9 +422,9 @@ A definition whose source range **overlaps an actual added, removed, or replaced
Use the ordinary **file-picker/file-explorer actions and bindings**: Space `o` opens a floating picker, Space `e` opens/focuses the docked explorer, and Ctrl+w closes the focused picker/explorer and reclaims its width. Remapped bindings work too; Ctrl+e is no longer reserved by the diff. The changed-path catalog retains A/M/D/R markers and the normal compact tree, filtering, folder expansion, and configurable `tree`/`selection` navigation. It includes removed and historical paths without scanning the working directory. Enter toggles a folder or opens the selected comparison file and focuses its first visible text pane: After for added files, otherwise Before. While the explorer is hidden, Tab/Shift+Tab cycles between visible text panes only; a single-pane view keeps its focus.
File rows show colored **A** (added, green), **M** (modified, yellow), **D** (removed, red), or **R** (renamed, cyan) markers in Tree and List views, including filtered results. Rename rows show `R old/path -> new/path`; directories have no file-status marker. Ordinary file exploration remains unannotated.
File rows show colored **A** (added, green), **M** (modified, yellow), **D** (removed, red), or **R** (renamed, cyan) markers in Tree and List views, including filtered results. Rename rows show `R old/path -> new/path`; directories have no file-status marker.
Mark or unmark a diff file with **`c` while browsing the changed-file tree**, **`:diff.review.toggle`**, or the command-palette action. With the changed-file explorer focused, it toggles the highlighted file without opening it; from a text pane, the command toggles the displayed file. Reviewed files show a green **`✓` immediately before A/M/D/R** in Tree and List views. Directories and ordinary filesystem pickers cannot be marked. While filtering, including List view, `c` remains query text; use the palette or a control-key binding there. Explicit bindings take precedence over the default browsing shortcut.
Mark or unmark a diff file with **`c` while browsing the changed-file tree**, **`:diff.review.toggle`**, or the command-palette action. With the changed-file explorer focused, it toggles the highlighted file without opening it; from a text pane, the command toggles the displayed file. Reviewed files show a green **`✓` immediately left of A/M/D/R, separated by one blank column**, in Tree and List views. The modification indicator keeps its existing indentation; the review marker uses the preceding gutter space instead of adding padding. Diff rows use the selection background and cursor rather than a `>` prefix. 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.
@@ -419,6 +442,8 @@ Diff views inherit the originating buffer's word-wrap setting. `word_wrap.toggle
In split diffs, changed text rows use muted red backgrounds in Before and muted green backgrounds in After, including blank tails and real empty lines. Entirely added or deleted files retain the normal background; colored line numbers and `+`/`-` gutter markers indicate their changes. Foreground colors use the editor's bundled Tree-sitter syntax highlighting, parsed independently for each snapshot and its path (including the old path for renames). Native text panes also display their buffer's selections, search matches, semantic highlighting, and diagnostics. Unknown languages retain the normal foreground; historical snapshots do not borrow live-buffer LSP tokens.
The row tint also covers the line-number gutter on wrapped continuations and blank wrap-padding rows on the shorter side of an aligned pair. Empty source lines keep the same full-row tint; missing source counterparts retain their normal-background hatching.
Character-wise highlighting is enabled automatically within paired changed lines: only changed graphemes receive a stronger red/green background, while common text retains the muted row tint. Combining sequences, wide characters, and expanded tabs keep their source geometry through horizontal scrolling and word wrap. Syntax/semantic foregrounds remain intact, and cursor/search/selection styles keep their normal precedence. Character ranges are cached with aligned rows; stable live edits refresh only the edited paired lines rather than computing character diffs during painting.
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.
@@ -431,6 +456,33 @@ Both diff panes apply available semantic tokens from their own buffer, independe
Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file.
## Jujutsu conflict resolution
Run **`:resolve`** in Normal mode for the current jj working copy. It opens a dedicated tab and selects the current conflicted file (otherwise the first conflict). Historical comparisons remain read-only; resolving never implicitly checks out or creates another change.
**Resolution** shows an editable **Draft** on the left and a read-only **Alternative** on the right, with aligned rows, line/character highlighting, context folds and synchronized scrolling. Both are projected from the current working buffer, including pre-existing unsaved edits: only conflict-marker regions are replaced by their selected alternatives. Already-merged text and edits outside those regions appear on both sides. The initial Draft is seeded from the first side, labelled in its border, and is a normal undoable buffer change; opening never writes it to disk. Alternatives remain frozen while later edits change only the Draft.
| Command | Behavior |
| --- | --- |
| `resolve.next` / `resolve.previous` | Jump between differing decision hunks |
| `resolve.source [N]` | Select/focus an Alternative; in History, select an outer input; without N, cycle |
| `resolve.base [N]` | Open History and select a numbered base; default first base |
| `resolve.use_source [N]` | Take the current differing hunk from that Alternative as one undoable edit |
| `resolve.next_file` / `resolve.previous_file` | Open another retained file |
| `resolve.files` / `resolve.draft` | Toggle/focus the file explorer / return to the editable Draft |
| `resolve.history` / `resolve.view.toggle` | Show read-only input/base/input History / switch views |
| `resolve.close` | Close the resolver without discarding dirty drafts |
**History** is separate from editing: it compares the original inputs and their base, with all sides and bases selectable for multi-sided conflicts. Draft text, undo, caret and viewport survive view switches. The file explorer is hidden by default and available on demand. Narrow Resolution panes retain side-by-side comparison where usable; narrow History shows the focused input. Native editing, copying, search and remapped pane navigation work normally; read-only Alternatives and History reject edits and saves.
Input indexes are one-based and include bases. Commands appear in the palette and through corresponding `config.core.resolve` actions; no new global default bindings are installed. Normal-mode Tab/Shift+Tab cycles visible panes and an open file list. In the list, `/` filters and Enter opens a file. The complete seeded Draft can be saved even when intentional differences remain against the Alternative: those differences are not unresolved conflict markers. There is no automatic “accept both”.
**`:w` saves and checks jj**, asynchronously. Before writing, the resolver locks the working copy and validates the frozen checkout and exact on-disk file state. Checkout changes, external writes and unsafe filesystem ancestors refuse the write, including force-save. Successful writes trigger jj's normal snapshot operation, which may capture other on-disk changes. The list distinguishes unresolved, unsaved, checking, resolved and unconfirmed states. jj—not marker scanning or a manual checkbox—confirms resolution. A failed confirmation keeps saved contents and reports unconfirmed status. Resolved files remain in the session for review. Dirty drafts prevent destructive resolver/pane/tab closes.
Binary, symlink, deletion, executable-bit and file/directory conflicts use explicit whole-file input choices: choose an input with `resolve.use_source`, then save to apply its exact bytes and type. Symlinks are never followed. Directory replacement refuses unrelated/untracked or modified child data. Git submodule choices and legacy jj conflict storage without `jj:trees` metadata report unsupported errors instead of fabricating a resolution.
Opening, navigating and choosing draft resolutions do not snapshot jj; only saving does. If another jj operation changes the frozen checkout, retain your draft, close after preserving it, and reopen the resolver against the new state.
## Command palette
Ctrl+p opens a searchable overlay without leaving the current mode. It lists each core action once, plus registered plugin commands, with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear.
+2
View File
@@ -9,6 +9,8 @@ Vamp.configure do |config|
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.tab_spaces = nil # opt in: 4 inserts four spaces per Tab
# config.language_tab_spaces.merge!("ruby" => 2, "makefile" => nil)
config.file_explorer_side = "right"
config.file_explorer_view = "tree"
config.file_picker_view = "tree"
+34
View File
@@ -0,0 +1,34 @@
Greeting: Hello
Shared context 01
Shared context 02
Shared context 03
Shared context 04
Shared context 05
Shared context 06
Shared context 07
Shared context 08
Shared context 09
Shared context 10
Shared context 11
Shared context 12
Shared context 13
Shared context 14
Shared context 15
Shared context 16
Shared context 17
Shared context 18
Shared context 19
Shared context 20
Shared context 21
Shared context 22
Shared context 23
Shared context 24
Shared context 25
Shared context 26
Shared context 27
Shared context 28
Shared context 29
Shared context 30
Farewell: Goodbye
+1
View File
@@ -0,0 +1 @@
Delivery: send the notification immediately
+1
View File
@@ -0,0 +1 @@
greeting.txt
+115 -24
View File
@@ -4,17 +4,6 @@ use std::time::Duration;
const MAX_CLIPBOARD_BYTES: usize = 4 * 1024 * 1024;
const COMMAND_TIMEOUT: Duration = Duration::from_secs(2);
#[cfg(target_os = "macos")]
const MAC_READ: &str = r#"
ObjC.import('AppKit');
function run() {
var text = $.NSPasteboard.generalPasteboard.stringForType($.NSPasteboardTypeString);
if (text.isNil()) return;
$.NSFileHandle.fileHandleWithStandardOutput.writeData(
text.dataUsingEncoding($.NSUTF8StringEncoding));
}
"#;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ClipboardMode {
System,
@@ -27,7 +16,7 @@ struct ClipboardCommand {
}
struct Commands {
reader: ClipboardCommand,
reader: Option<ClipboardCommand>,
writer: Option<ClipboardCommand>,
}
@@ -54,10 +43,10 @@ impl SystemClipboard {
pub(crate) fn with_commands(reader: PathBuf, writer: PathBuf) -> Self {
Self {
commands: Some(Ok(Commands {
reader: ClipboardCommand {
reader: Some(ClipboardCommand {
program: reader,
args: &[],
},
}),
writer: Some(ClipboardCommand {
program: writer,
args: &[],
@@ -70,10 +59,17 @@ impl SystemClipboard {
pub fn read(&mut self) -> Result<String, String> {
let timeout = self.timeout;
let commands = self.commands.get_or_insert_with(select_commands);
let command = &commands.as_ref().map_err(Clone::clone)?.reader;
match &commands.as_ref().map_err(Clone::clone)?.reader {
Some(command) => {
let bytes = run(command, None, timeout)?;
String::from_utf8(bytes).map_err(|_| "System clipboard is not valid UTF-8".into())
}
#[cfg(target_os = "macos")]
None => macos::read(),
#[cfg(not(target_os = "macos"))]
None => Err("System clipboard is unsupported on this platform".into()),
}
}
pub fn write(&mut self, text: &str) -> Result<(), String> {
if text.len() > MAX_CLIPBOARD_BYTES {
@@ -91,7 +87,7 @@ impl SystemClipboard {
}
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[cfg(target_os = "linux")]
fn executable(name: &str) -> Option<PathBuf> {
use std::os::unix::fs::PermissionsExt;
@@ -109,10 +105,7 @@ fn select_commands() -> Result<Commands, String> {
#[cfg(target_os = "macos")]
{
Ok(Commands {
reader: ClipboardCommand {
program: executable("osascript").unwrap_or_else(|| "/usr/bin/osascript".into()),
args: &["-l", "JavaScript", "-e", MAC_READ],
},
reader: None,
writer: None,
})
}
@@ -121,10 +114,10 @@ fn select_commands() -> Result<Commands, String> {
if std::env::var_os("WAYLAND_DISPLAY").is_some_and(|value| !value.is_empty()) {
if let (Some(reader), Some(writer)) = (executable("wl-paste"), executable("wl-copy")) {
return Ok(Commands {
reader: ClipboardCommand {
reader: Some(ClipboardCommand {
program: reader,
args: &["--no-newline", "--type", "text"],
},
}),
writer: Some(ClipboardCommand {
program: writer,
args: &["--type", "text/plain;charset=utf-8"],
@@ -135,10 +128,10 @@ fn select_commands() -> Result<Commands, String> {
if std::env::var_os("DISPLAY").is_some_and(|value| !value.is_empty()) {
if let Some(program) = executable("xclip") {
return Ok(Commands {
reader: ClipboardCommand {
reader: Some(ClipboardCommand {
program: program.clone(),
args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-out"],
},
}),
writer: Some(ClipboardCommand {
program,
args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-in"],
@@ -250,6 +243,85 @@ mod macos {
write_to(name.0, text)
}
#[link(name = "AppKit", kind = "framework")]
unsafe extern "C" {
static NSPasteboardTypeString: Object;
}
#[link(name = "objc")]
unsafe extern "C" {
fn objc_getClass(name: *const c_char) -> Object;
fn sel_registerName(name: *const c_char) -> Object;
fn objc_msgSend();
fn objc_autoreleasePoolPush() -> Object;
fn objc_autoreleasePoolPop(pool: Object);
}
struct AutoreleasePool(Object);
impl Drop for AutoreleasePool {
fn drop(&mut self) {
// SAFETY: this pool was pushed on the same thread.
unsafe { objc_autoreleasePoolPop(self.0) };
}
}
fn read_from(board: Object) -> Result<String, String> {
// SAFETY: receivers are live AppKit objects; each cast matches the
// selected Objective-C method's ABI. The caller owns an autorelease pool.
unsafe {
let string: unsafe extern "C" fn(Object, Object, Object) -> Object =
std::mem::transmute(objc_msgSend as unsafe extern "C" fn());
let text = string(
board,
sel_registerName(c"stringForType:".as_ptr()),
NSPasteboardTypeString,
);
if text.is_null() {
return Ok(String::new());
}
let encode: unsafe extern "C" fn(Object, Object, usize) -> Object =
std::mem::transmute(objc_msgSend as unsafe extern "C" fn());
let data = encode(text, sel_registerName(c"dataUsingEncoding:".as_ptr()), 4);
if data.is_null() {
return Err("System clipboard is not valid UTF-8".into());
}
let length: unsafe extern "C" fn(Object, Object) -> usize =
std::mem::transmute(objc_msgSend as unsafe extern "C" fn());
let length = length(data, sel_registerName(c"length".as_ptr()));
if length > super::MAX_CLIPBOARD_BYTES {
return Err("System clipboard text exceeds 4 MiB".into());
}
if length == 0 {
return Ok(String::new());
}
let bytes: unsafe extern "C" fn(Object, Object) -> *const u8 =
std::mem::transmute(objc_msgSend as unsafe extern "C" fn());
let bytes = std::slice::from_raw_parts(
bytes(data, sel_registerName(c"bytes".as_ptr())),
length,
);
String::from_utf8(bytes.to_vec())
.map_err(|_| "System clipboard is not valid UTF-8".into())
}
}
pub(super) fn read() -> Result<String, String> {
// SAFETY: AppKit class and selector names are static C strings.
unsafe {
let _pool = AutoreleasePool(objc_autoreleasePoolPush());
let send: unsafe extern "C" fn(Object, Object) -> Object =
std::mem::transmute(objc_msgSend as unsafe extern "C" fn());
let board = send(
objc_getClass(c"NSPasteboard".as_ptr()),
sel_registerName(c"generalPasteboard".as_ptr()),
);
if board.is_null() {
return Err("Cannot open system clipboard".into());
}
read_from(board)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -272,6 +344,7 @@ mod macos {
#[test]
fn native_publication_preserves_plain_utf8_bytes_on_an_isolated_pasteboard() {
// A private pasteboard leaves the user's clipboard untouched.
let _pool = unsafe { AutoreleasePool(objc_autoreleasePoolPush()) };
let name = unsafe {
Owned::new(CFStringCreateWithCString(
ptr::null(),
@@ -285,6 +358,15 @@ mod macos {
check(PasteboardCreate(name.0, &mut board)).unwrap();
Owned::new(board).unwrap()
};
let reader = unsafe {
let send: unsafe extern "C" fn(Object, Object, Object) -> Object =
std::mem::transmute(objc_msgSend as unsafe extern "C" fn());
send(
objc_getClass(c"NSPasteboard".as_ptr()),
sel_registerName(c"pasteboardWithName:".as_ptr()),
name.0,
)
};
for text in [
"",
"é e\u{301} 👨‍💻\n\0tail",
@@ -292,6 +374,7 @@ mod macos {
"%!PS-Adobe-3.0\nplain",
] {
write_to(name.0, text).unwrap();
assert_eq!(read_from(reader).unwrap(), text);
unsafe {
PasteboardSynchronize(board.0);
let mut item = 0;
@@ -317,6 +400,14 @@ mod macos {
assert_eq!(bytes, text.as_bytes());
}
}
let maximum = "x".repeat(super::super::MAX_CLIPBOARD_BYTES);
write_to(name.0, &maximum).unwrap();
assert_eq!(read_from(reader).unwrap(), maximum);
write_to(name.0, &(maximum + "x")).unwrap();
assert_eq!(
read_from(reader).unwrap_err(),
"System clipboard text exceeds 4 MiB"
);
}
}
}
+696 -27
View File
@@ -19,6 +19,9 @@ pub enum Category {
Commits,
Branches,
Tags,
Open,
Closed,
All,
}
impl Category {
pub fn label(self) -> &'static str {
@@ -28,6 +31,9 @@ impl Category {
Self::Commits => "Commits",
Self::Branches => "Branches",
Self::Tags => "Tags",
Self::Open => "Open",
Self::Closed => "Closed",
Self::All => "All",
}
}
}
@@ -57,7 +63,19 @@ impl ComparisonMode {
pub enum RevisionTarget {
Working,
Commit(String),
Remote { remote: String, reference: String },
Remote {
remote: String,
reference: String,
},
RemoteCommit {
remote: String,
reference: String,
id: String,
},
PullRequest {
remote: String,
number: u64,
},
ListRemote(String),
FetchRemote(String),
}
@@ -74,6 +92,19 @@ pub struct Catalog {
pub entries: Vec<Revision>,
pub notice: Option<String>,
}
#[derive(Debug)]
pub struct PullRequestPage {
pub kind: RepoKind,
pub remote: String,
pub entries: Vec<(Revision, String)>,
pub has_more: bool,
}
#[derive(Debug, Default)]
struct PullRequestCategory {
complete: bool,
error: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FileStatus {
Added,
@@ -114,6 +145,8 @@ pub struct Comparison {
#[derive(Debug)]
pub enum Operation {
Discover,
PullRequests(String),
ResolvePullRequest(Revision),
ListRemote(String),
FetchRemote(String),
Compare {
@@ -135,6 +168,14 @@ pub enum Operation {
#[derive(Debug)]
pub enum Outcome {
Catalog(Catalog),
PullRequestPage {
closed: bool,
result: Result<PullRequestPage, String>,
},
ResolvedPullRequest {
before: Revision,
after: Revision,
},
Comparison(Comparison),
CheckoutRefresh(Option<Comparison>),
File {
@@ -208,6 +249,10 @@ enum RequestKind {
#[derive(Debug)]
pub struct DiffState {
pub catalog: Option<Catalog>,
pub pull_request_remote: Option<String>,
pull_request_categories: [PullRequestCategory; 2],
pull_request_order: BTreeMap<u64, (String, usize)>,
pub(crate) pull_request_retries: [u64; 2],
pub phase: Phase,
pub word_wrap: bool,
pub view_mode: ViewMode,
@@ -256,6 +301,10 @@ impl DiffState {
pub(crate) fn new() -> Self {
Self {
catalog: None,
pull_request_remote: None,
pull_request_categories: Default::default(),
pull_request_order: BTreeMap::new(),
pull_request_retries: [0; 2],
phase: Phase::Before,
word_wrap: false,
view_mode: ViewMode::Split,
@@ -301,6 +350,105 @@ impl DiffState {
}
}
pub(crate) fn set_resolver_text(&mut self, draft: &[String], alternative: &[String]) {
fn content(lines: &[String]) -> (&[String], bool) {
let eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty);
let content = if eof {
&lines[..lines.len() - 1]
} else {
lines
};
(
if !eof && content.len() == 1 && content[0].is_empty() {
&[]
} else {
content
},
eof,
)
}
let (before, before_eof) = content(draft);
let (after, after_eof) = content(alternative);
if self.comparison.as_ref().is_some_and(|comparison| {
let file = &comparison.files[0];
file.before == before
&& file.after == after
&& file.before_eof == before_eof
&& file.after_eof == after_eof
}) {
return;
}
let viewport = (self.top, self.left_before, self.left_after);
self.phase = Phase::View;
self.loading = false;
self.pending = None;
self.request_kind = None;
self.files_visible = false;
self.comparison_id = Some(self.comparison_id.unwrap_or(0).wrapping_add(1));
self.file = 0;
self.live_files.clear();
self.comparison = Some(Comparison {
before: "Draft".into(),
after: "Alternative".into(),
root: PathBuf::new(),
editable_paths: BTreeSet::new(),
files: vec![ChangedFile {
path: PathBuf::new(),
old_path: None,
status: FileStatus::Modified,
loaded: true,
before: before.to_vec(),
after: after.to_vec(),
before_eof,
after_eof,
hunks: line_hunks(before, before_eof, after, after_eof),
notice: None,
}],
});
self.focus = if self.focus == Focus::After {
Focus::After
} else {
Focus::Before
};
self.align_file();
let before_terminal = (draft.len() > before.len()).then_some(before.len());
let after_terminal = (alternative.len() > after.len()).then_some(after.len());
if before_terminal.is_some() || after_terminal.is_some() {
self.rows.push(AlignedRow {
before: before_terminal,
after: after_terminal,
kind: RowKind::Equal,
changes: Vec::new(),
});
self.reset_excerpts();
}
if self.comparison.as_ref().unwrap().files[0].hunks.is_empty() {
self.excerpt_ranges = vec![(0, self.rows.len())];
self.rebuild_display_rows();
}
self.top = viewport.0.min(self.display_rows.len().saturating_sub(1));
self.left_before = viewport.1.min(self.max_before);
self.left_after = viewport.2.min(self.max_after);
}
pub(crate) fn pull_requests(remote: String) -> Self {
Self {
pull_request_remote: Some(remote.clone()),
pending: Some(Operation::PullRequests(remote)),
..Self::new()
}
}
pub fn categories(&self) -> &'static [Category] {
if self.pull_request_remote.is_some() {
&[Category::Open, Category::Closed, Category::All]
} else {
self.catalog
.as_ref()
.map_or(&[], |catalog| catalog.kind.categories())
}
}
pub fn entries(&self) -> impl ExactSizeIterator<Item = &Revision> {
self.visible
.iter()
@@ -363,7 +511,7 @@ impl DiffState {
return;
}
self.request_kind = Some(match operation {
Operation::Compare { .. } => RequestKind::Comparison,
Operation::Compare { .. } | Operation::ResolvePullRequest(_) => RequestKind::Comparison,
Operation::LoadFile {
comparison_id,
file_index,
@@ -379,6 +527,71 @@ impl DiffState {
}
pub(crate) fn apply_result(&mut self, id: u64, result: Result<Outcome, String>) -> bool {
if let Ok(Outcome::PullRequestPage { closed, result }) = result {
if self.pull_request_remote.is_none()
|| self.request_kind != Some(RequestKind::Catalog)
|| self.pending.is_some()
{
return false;
}
self.error = None;
let status = &mut self.pull_request_categories[usize::from(closed)];
match result {
Ok(page) => {
status.complete = !page.has_more;
status.error = None;
let selected = self
.entries()
.nth(self.selected)
.map(|entry| entry.target.clone());
let catalog = self.catalog.get_or_insert_with(|| Catalog {
kind: page.kind,
entries: Vec::new(),
notice: Some(format!("Pull requests from {}", page.remote)),
});
for (revision, created_at) in page.entries {
let RevisionTarget::PullRequest { number, .. } = &revision.target else {
continue;
};
if let Some((date, index)) = self.pull_request_order.get_mut(number) {
*date = created_at;
catalog.entries[*index] = revision;
} else {
self.pull_request_order
.insert(*number, (created_at, catalog.entries.len()));
catalog.entries.push(revision);
}
}
let number = |revision: &Revision| match revision.target {
RevisionTarget::PullRequest { number, .. } => number,
_ => unreachable!("pull request catalog"),
};
catalog.entries.sort_unstable_by(|a, b| {
self.pull_request_order[&number(b)]
.0
.cmp(&self.pull_request_order[&number(a)].0)
.then_with(|| number(b).cmp(&number(a)))
});
for (index, revision) in catalog.entries.iter().enumerate() {
self.pull_request_order
.get_mut(&number(revision))
.expect("known pull request")
.1 = index;
}
self.prepare_search();
self.refilter();
if let Some(target) = selected {
self.select_target(&target);
}
}
Err(error) => status.error = Some(error),
}
self.loading = self
.pull_request_categories
.iter()
.any(|status| !status.complete && status.error.is_none());
return true;
}
if self.request_kind.is_none() {
return false;
}
@@ -487,28 +700,21 @@ impl DiffState {
self.loading = false;
self.pending = None;
match result {
Ok(Outcome::ResolvedPullRequest { before, after })
if expected == Some(RequestKind::Comparison) =>
{
self.before = Some(before);
self.after = Some(after);
self.queue_comparison();
}
Ok(Outcome::Catalog(catalog)) if expected == Some(RequestKind::Catalog) => {
let selected = self
.entries()
.nth(self.selected)
.map(|entry| entry.target.clone());
self.search_fields = catalog
.entries
.iter()
.map(|entry| {
let id = match &entry.target {
RevisionTarget::Commit(id) => id.as_str(),
_ => "",
};
[
entry.label.to_lowercase(),
id.to_lowercase(),
entry.description.to_lowercase(),
]
})
.collect();
self.category = self.category.min(catalog.kind.categories().len() - 1);
self.catalog = Some(catalog);
self.prepare_search();
self.category = self.category.min(self.categories().len() - 1);
self.refilter();
if let Some(target) = selected {
self.select_target(&target);
@@ -583,15 +789,90 @@ impl DiffState {
}
true
}
fn prepare_search(&mut self) {
self.search_fields = self
.catalog
.as_ref()
.into_iter()
.flat_map(|catalog| &catalog.entries)
.map(|entry| {
let id = match &entry.target {
RevisionTarget::Commit(id) => id.as_str(),
_ => "",
};
[
entry.label.to_lowercase(),
id.to_lowercase(),
entry.description.to_lowercase(),
]
})
.collect();
}
pub(crate) fn pull_request_partial(&self) -> bool {
self.pull_request_remote.is_some()
&& self
.pull_request_categories
.iter()
.enumerate()
.any(|(index, status)| {
(self.category == 2 || self.category == index) && !status.complete
})
}
pub(crate) fn pull_request_notice(&self) -> Option<String> {
self.pull_request_remote.as_ref()?;
let statuses: Vec<_> = self
.pull_request_categories
.iter()
.enumerate()
.filter(|(index, _)| self.category == 2 || self.category == *index)
.map(|(index, status)| {
let category = [Category::Open, Category::Closed][index];
let count = self.catalog.as_ref().map_or(0, |catalog| {
catalog
.entries
.iter()
.filter(|entry| entry.category == category)
.count()
});
let state = status.error.as_deref().unwrap_or(if status.complete {
"complete"
} else {
"loading…"
});
format!("{}: {count} loaded · {state}", category.label())
})
.collect();
Some(format!(
"{}{}",
statuses.join(" · "),
if self.pull_request_partial() {
" · partial search"
} else {
""
}
))
}
fn retry_pull_requests(&mut self) {
for (index, status) in self.pull_request_categories.iter_mut().enumerate() {
if (self.category == 2 || self.category == index) && status.error.take().is_some() {
self.pull_request_retries[index] += 1;
self.loading = true;
}
}
}
fn refilter(&mut self) {
self.visible.clear();
self.search_matches.clear();
if let Some(catalog) = &self.catalog {
let category = catalog.kind.categories()[self.category];
let category = self.categories()[self.category];
let query = self.query.to_lowercase();
let tokens: Vec<_> = query.split_whitespace().collect();
// Working is always pinned, even outside its category or query.
// Repository revisions pin Working outside its category and query.
if self.pull_request_remote.is_none() {
self.visible.extend(
catalog
.entries
@@ -600,8 +881,13 @@ impl DiffState {
.filter(|(_, entry)| entry.target == RevisionTarget::Working)
.map(|(index, _)| index),
);
}
for (index, entry) in catalog.entries.iter().enumerate() {
if entry.target == RevisionTarget::Working || entry.category != category {
if entry.target == RevisionTarget::Working
|| (entry.category != category
&& !(category == Category::All
&& matches!(entry.category, Category::Open | Category::Closed)))
{
continue;
}
if tokens.is_empty() {
@@ -704,8 +990,8 @@ impl DiffState {
}
match input {
Input::Char('\t') | Input::BackTab => {
if let Some(catalog) = &self.catalog {
let count = catalog.kind.categories().len();
let count = self.categories().len();
if count > 0 {
self.category = if input == Input::BackTab {
(self.category + count - 1) % count
} else {
@@ -718,7 +1004,14 @@ impl DiffState {
Input::Down | Input::Ctrl('j') => self.navigate(true),
Input::Home => self.selected = 0,
Input::End => self.selected = self.visible.len().saturating_sub(1),
Input::Enter if !self.loading => self.choose(),
Input::Enter
if !self.loading
|| (self.pull_request_remote.is_some()
&& self.request_kind == Some(RequestKind::Catalog)) =>
{
self.choose()
}
Input::Ctrl('r') if self.pull_request_remote.is_some() => self.retry_pull_requests(),
input if BackspaceKind::from_input(&input).is_some() => {
if BackspaceKind::from_input(&input)
.expect("guard checked backspace input")
@@ -742,12 +1035,26 @@ impl DiffState {
fn choose(&mut self) {
let revision = self.entries().nth(self.selected).cloned();
let Some(revision) = revision else {
// Discovery can be retried without closing the modal.
if self.catalog.is_none() {
self.queue(Operation::Discover);
self.retry_pull_requests();
// Listing can be retried without closing the modal.
if self.catalog.is_none()
&& (self.pull_request_remote.is_none() || self.error.is_some())
{
self.queue(match &self.pull_request_remote {
Some(remote) => Operation::PullRequests(remote.clone()),
None => Operation::Discover,
});
}
return;
};
if matches!(revision.target, RevisionTarget::PullRequest { .. }) {
self.before = None;
self.after = None;
self.comparison_mode = ComparisonMode::BranchChanges;
self.phase = Phase::After;
self.queue(Operation::ResolvePullRequest(revision));
return;
}
if revision.target == RevisionTarget::Working {
self.comparison_mode = ComparisonMode::Snapshots;
}
@@ -776,6 +1083,9 @@ impl DiffState {
}
pub(crate) fn toggle_comparison_mode(&mut self) -> Result<(), &'static str> {
if self.pull_request_remote.is_some() && self.after.is_none() {
return Err("Select a pull request before changing comparison mode");
}
if self.working_selected() {
return Err("Working checkout requires Snapshots");
}
@@ -2062,6 +2372,28 @@ mod tests {
pub(super) static WIDTH_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[test]
fn resolver_equal_text_retains_native_rows_and_cached_viewport() {
for text in ["", "\n", "same\n", "same"] {
let lines: Vec<_> = text.split('\n').map(str::to_owned).collect();
let mut state = DiffState::new();
state.set_resolver_text(&lines, &lines);
assert_eq!(state.pane_layout(), PaneLayout::Split);
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty());
for row in 0..lines.len() {
assert!(state.source_display_row(Focus::Before, row).is_some());
assert!(state.source_display_row(Focus::After, row).is_some());
}
let id = state.comparison_id;
state.top = 1;
state.left_before = 3;
state.left_after = 4;
state.set_resolver_text(&lines, &lines);
assert_eq!(state.comparison_id, id);
assert_eq!((state.top, state.left_before, state.left_after), (1, 3, 4));
}
}
fn excerpt_state(len: usize, changed: &[usize]) -> DiffState {
let mut state = DiffState::new();
state.phase = Phase::View;
@@ -2332,6 +2664,343 @@ mod tests {
}
}
fn pull_page(closed: bool, rows: &[(u64, &str, &str)], has_more: bool) -> Outcome {
Outcome::PullRequestPage {
closed,
result: Ok(PullRequestPage {
kind: RepoKind::Git,
remote: "origin".into(),
has_more,
entries: rows
.iter()
.map(|&(number, title, date)| {
(
revision(
RevisionTarget::PullRequest {
remote: "origin".into(),
number,
},
&format!("#{number} {title}"),
"Alice · main ← topic",
if closed {
Category::Closed
} else {
Category::Open
},
),
date.into(),
)
})
.collect(),
}),
}
}
#[test]
fn pull_request_categories_keep_query_and_select_loaded_rows_before_completion() {
let mut state = DiffState::pull_requests("origin".into());
assert_eq!(
state.categories(),
[Category::Open, Category::Closed, Category::All]
);
state.take_request();
state.handle(Input::Char('\t'));
for ch in "FIX üni Alice".chars() {
state.handle(Input::Char(ch));
}
state.apply_result(
1,
Ok(pull_page(
true,
&[(2, "Fix Ünicode", "2026-01-02T00:00:00Z")],
true,
)),
);
assert!(state.loading);
assert!(state.pull_request_partial());
assert_eq!(state.entries().next().unwrap().label, "#2 Fix Ünicode");
state.handle(Input::Char('\t'));
state.apply_result(
1,
Ok(pull_page(
false,
&[
(1, "Fix Ünicode", "2026-01-03T00:00:00Z"),
(3, "Other work", "2026-01-01T00:00:00Z"),
],
true,
)),
);
assert_eq!(
state
.entries()
.map(|entry| entry.label.as_str())
.collect::<Vec<_>>(),
["#1 Fix Ünicode", "#2 Fix Ünicode"]
);
state.handle(Input::Char('\t'));
assert_eq!(state.query, "FIX üni Alice");
state.comparison_mode = ComparisonMode::Snapshots;
state.handle(Input::Enter);
assert_eq!(state.phase, Phase::After);
assert!(state.before.is_none() && state.after.is_none());
assert_eq!(state.comparison_mode, ComparisonMode::BranchChanges);
assert!(matches!(
state.take_request(),
Some(Operation::ResolvePullRequest(Revision {
target: RevisionTarget::PullRequest { number: 1, .. },
..
}))
));
}
#[test]
fn pull_request_all_streams_sort_deduplicate_and_preserve_selection_identity() {
let mut state = DiffState::pull_requests("origin".into());
state.take_request();
state.handle(Input::BackTab);
state.apply_result(
1,
Ok(pull_page(
false,
&[
(1, "First", "2026-01-01T00:00:00Z"),
(2, "Second", "2026-01-02T00:00:00Z"),
],
true,
)),
);
state.handle(Input::End);
assert_eq!(
state.entries().nth(state.selected).unwrap().label,
"#1 First"
);
state.apply_result(1, Ok(pull_page(true, &[], true)));
assert!(state.pull_request_partial());
state.apply_result(
1,
Ok(pull_page(
true,
&[(3, "Newest", "2026-01-03T00:00:00Z")],
true,
)),
);
assert_eq!(
state.entries().nth(state.selected).unwrap().label,
"#1 First"
);
state.apply_result(
1,
Ok(pull_page(
false,
&[(2, "Second updated", "2026-01-02T00:00:00Z")],
false,
)),
);
assert_eq!(state.entries().len(), 3);
assert_eq!(
state
.entries()
.map(|entry| entry.label.as_str())
.collect::<Vec<_>>(),
["#3 Newest", "#2 Second updated", "#1 First"]
);
assert!(state.pull_request_partial());
state.apply_result(1, Ok(pull_page(true, &[], false)));
assert!(!state.pull_request_partial());
assert!(!state.loading);
assert_eq!(
state.entries().nth(state.selected).unwrap().label,
"#1 First"
);
}
#[test]
fn pull_request_late_category_errors_retry_without_losing_rows_or_other_results() {
let mut editor = editor();
editor.execute("pull_requests origin");
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(
id,
Ok(pull_page(
false,
&[(1, "Open", "2026-01-01T00:00:00Z")],
true,
)),
);
editor.apply_diff_result(
id,
Ok(pull_page(
true,
&[(2, "Closed", "2026-01-02T00:00:00Z")],
true,
)),
);
editor.handle(Input::Char('\t'));
editor.apply_diff_result(
id,
Ok(Outcome::PullRequestPage {
closed: true,
result: Err("offline".into()),
}),
);
let diff = editor.diff().unwrap();
assert!(diff.pull_request_notice().unwrap().contains("offline"));
assert_eq!(diff.entries().next().unwrap().label, "#2 Closed");
assert!(diff.pull_request_partial());
editor.handle(Input::Ctrl('r'));
assert_eq!(editor.diff().unwrap().pull_request_retries, [0, 1]);
assert!(editor.take_diff_request().is_none());
editor.apply_diff_result(
id,
Ok(pull_page(
false,
&[(3, "More open", "2026-01-03T00:00:00Z")],
false,
)),
);
assert_eq!(
editor.diff().unwrap().entries().next().unwrap().label,
"#2 Closed"
);
editor.apply_diff_result(id, Ok(pull_page(true, &[], false)));
assert!(!editor.diff().unwrap().pull_request_partial());
editor.handle(Input::Enter);
let (_, operation) = editor.take_diff_request().unwrap();
assert!(matches!(operation, Operation::ResolvePullRequest(_)));
assert!(!editor.apply_diff_result(id, Ok(pull_page(false, &[], false))));
}
#[test]
fn pull_request_loading_retry_escape_and_reopen_reject_stale_results() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("unsaved source".into())])
.unwrap();
editor.execute("pull_requests origin");
let (first, _) = editor.take_diff_request().unwrap();
editor.handle(Input::Enter);
assert!(editor.take_diff_request().is_none());
text(&mut editor, "alice");
editor.apply_diff_result(
first,
Ok(Outcome::PullRequestPage {
closed: false,
result: Err("API unavailable".into()),
}),
);
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().pull_request_retries, [1, 0]);
assert!(editor.take_diff_request().is_none());
assert!(editor.apply_diff_result(
first,
Ok(pull_page(
false,
&[
(1, "Open", "2026-01-02T00:00:00Z"),
(2, "Other open", "2026-01-01T00:00:00Z"),
],
true
))
));
editor.handle(Input::Enter);
let (resolve, operation) = editor.take_diff_request().unwrap();
assert!(matches!(operation, Operation::ResolvePullRequest(_)));
assert!(!editor.apply_diff_result(first, Ok(pull_page(true, &[], false))));
editor.apply_diff_result(resolve, Err("Historical head no longer retained".into()));
assert!(editor.diff().unwrap().before.is_none());
assert!(editor.diff().unwrap().after.is_none());
editor.handle(Input::Down);
editor.handle(Input::Enter);
let (retry, operation) = editor.take_diff_request().unwrap();
assert!(matches!(
operation,
Operation::ResolvePullRequest(Revision {
target: RevisionTarget::PullRequest { number: 2, .. },
..
})
));
editor.handle(Input::Escape);
assert!(editor.diff().is_none());
assert!(!editor.apply_diff_result(retry, Ok(Outcome::Comparison(comparison()))));
assert_eq!(editor.lines(), ["unsaved source"]);
assert!(editor.dirty());
editor.execute("pull_requests");
let (reopened, _) = editor.take_diff_request().unwrap();
assert!(!editor.apply_diff_result(first, Ok(pull_page(false, &[], false))));
assert!(editor.apply_diff_result(reopened, Ok(pull_page(false, &[], false))));
assert!(editor.apply_diff_result(reopened, Ok(pull_page(true, &[], false))));
editor.handle(Input::Enter);
assert!(editor.take_diff_request().is_none());
}
#[test]
fn resolved_pull_request_modes_reuse_pinned_endpoints_without_resolving_again() {
let mut state = DiffState::pull_requests("origin".into());
state.take_request();
state.apply_result(
1,
Ok(pull_page(
false,
&[(1, "Open", "2026-01-01T00:00:00Z")],
true,
)),
);
state.handle(Input::Enter);
state.take_request();
let before = revision(
RevisionTarget::RemoteCommit {
remote: "origin".into(),
reference: "refs/heads/main".into(),
id: "base-sha".into(),
},
"main",
"",
Category::Open,
);
let after = revision(
RevisionTarget::RemoteCommit {
remote: "origin".into(),
reference: "refs/pull/1/head".into(),
id: "head-sha".into(),
},
"#1 Open",
"",
Category::Open,
);
state.apply_result(
2,
Ok(Outcome::ResolvedPullRequest {
before: before.clone(),
after: after.clone(),
}),
);
assert!(matches!(
state.take_request(),
Some(Operation::Compare {
mode: ComparisonMode::BranchChanges,
..
})
));
state.apply_result(3, Err("No common ancestor".into()));
assert_eq!(state.before.as_ref(), Some(&before));
assert_eq!(state.after.as_ref(), Some(&after));
state.toggle_comparison_mode().unwrap();
let Some(Operation::Compare {
before: pinned_before,
after: pinned_after,
mode,
..
}) = state.take_request()
else {
panic!("expected ordinary pinned comparison");
};
assert_eq!(
(pinned_before, pinned_after, mode),
(before, after, ComparisonMode::Snapshots)
);
}
fn editor() -> Editor {
Editor::open(None, vec![]).unwrap()
}
+2027 -43
View File
File diff suppressed because it is too large Load Diff
-9
View File
@@ -101,7 +101,6 @@ pub struct FilePicker {
tree_searching: bool,
warning: Option<String>,
repository_changes: Vec<WorkingFileChange>,
has_repository_changes: bool,
has_virtual_entries: bool,
}
@@ -307,7 +306,6 @@ impl FilePicker {
tree_searching: false,
warning,
repository_changes: Vec::new(),
has_repository_changes: false,
};
for root in roots {
let tree = picker.compact(root, None);
@@ -780,10 +778,6 @@ impl FilePicker {
}
}
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;
@@ -919,7 +913,6 @@ impl FilePicker {
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
}
@@ -1125,7 +1118,6 @@ mod tests {
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"] {
@@ -1166,7 +1158,6 @@ mod tests {
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!(
+1241
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -5,6 +5,7 @@ pub mod clipboard;
pub mod diff;
pub mod editor;
pub mod files;
mod forge;
pub mod gutter;
pub mod lsp;
mod lsp_config;
@@ -12,6 +13,7 @@ mod markdown;
pub mod panes;
pub mod plugin;
pub mod repository;
pub mod resolve;
pub mod ruby;
pub mod session;
mod snippet;
+3 -3
View File
@@ -11,9 +11,9 @@ pub fn resolve(value: Value) -> Result<Vec<ServerConfig>, String> {
return Err("LSP language keys must not be empty or contain control characters".into());
}
let preset: Option<(&[&[&str]], &str)> = match language_id.as_str() {
"rust" => Some((&[&["rust-analyzer"]], "rs")),
"python" => Some((&[&["ty", "server"], &["ruff", "server"]], "py")),
"ruby" => Some((&[&["solargraph", "stdio"]], "rb")),
crate::syntax::RUST_ID => Some((&[&["rust-analyzer"]], "rs")),
crate::syntax::PYTHON_ID => Some((&[&["ty", "server"], &["ruff", "server"]], "py")),
crate::syntax::RUBY_ID => Some((&[&["solargraph", "stdio"]], "rb")),
_ => None,
};
let mut options = match value {
+10 -2
View File
@@ -37,7 +37,7 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
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
Esc keeps only the primary caret
Visual: motions extend selections, y copy, d/x/Delete cut, c change
Visual: motions extend selections, y copy, d/x/Delete cut, c change, p/P replace selections
r<char> replaces every selected grapheme, preserving newlines; one undo action
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
@@ -45,7 +45,7 @@ Visual: motions extend selections, y copy, d/x/Delete cut, c change
Insert: Ctrl+n requests LSP completion at primary caret, then cycles next; Ctrl+j/k next/previous
Candidates replace text live; Original text restores it; other keys close and act normally
Snippets expand fully: type replaces argument, Tab/Shift+Tab next/previous, final $0 ends
Outside snippets Enter inserts newline, Tab inserts tab; Esc keeps text and returns to Normal
Outside snippets Enter inserts newline, Tab inserts a tab or configured spaces; Esc returns to Normal
Insert/Command/Search: Ctrl+u deletes backward to the logical line start
Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps)
Commands: :save [PATH], :force_save [PATH], :quit, :force_quit,
@@ -81,6 +81,14 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
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
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/All tabs, type to search
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
Resolve jj conflicts: :resolve opens the current working-copy conflicts in a dedicated workspace
Editable marker-free Draft vs readonly Alternative; :resolve.history shows inputs/base
:resolve.next/previous navigates differing hunks; :resolve.use_source [N] takes a hunk
:resolve.source N selects Alternative; :resolve.base N selects History base
:resolve.draft returns to Draft; :resolve.view.toggle switches Resolution/History
:w saves and checks jj; :resolve.files toggles the explorer; :resolve.close closes cleanly
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
Set VAMP_RUBY to choose the Ruby executable.
+241 -64
View File
@@ -1,7 +1,7 @@
//! Read-only active-repository discovery and isolated revision comparison.
use crate::diff::{
Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus, RepoKind, Revision,
RevisionTarget, line_hunks,
Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus, PullRequestPage,
RepoKind, Revision, RevisionTarget, line_hunks,
};
use std::{
collections::{BTreeMap, BTreeSet},
@@ -54,7 +54,7 @@ impl Drop for Temporary {
}
}
struct ProcessOutput {
pub(crate) struct ProcessOutput {
code: Option<i32>,
stdout: Vec<u8>,
stderr: Vec<u8>,
@@ -97,7 +97,7 @@ fn terminate(child: &mut std::process::Child) {
let _ = child.kill();
let _ = child.wait();
}
fn run(
pub(crate) fn run(
mut command: Command,
input: Vec<u8>,
cancelled: &AtomicBool,
@@ -200,7 +200,7 @@ fn run(
})
}
}
fn checked(output: ProcessOutput) -> Result<Vec<u8>, String> {
pub(crate) fn checked(output: ProcessOutput) -> Result<Vec<u8>, String> {
if output.code == Some(0) {
Ok(output.stdout)
} else {
@@ -1048,6 +1048,8 @@ struct FileData {
mode: String,
bytes: Vec<u8>,
object: Option<(PathBuf, String)>,
// Conflicts retain their Git blob identity, but selected contents come from jj.
projection: Option<(PathBuf, String)>,
notice: Option<String>,
}
type Snapshot = BTreeMap<PathBuf, FileData>;
@@ -1535,9 +1537,123 @@ impl Repository {
self.load_remote(remote, None)?;
self.catalog()
}
fn pull_request_remote(
&mut self,
name: &str,
) -> Result<(String, String, Option<String>), String> {
self.discover_remotes()?;
let remote = if name.is_empty() {
self.remotes
.iter()
.find(|remote| remote.name == "origin")
.or_else(|| (self.remotes.len() == 1).then(|| &self.remotes[0]))
.ok_or("Choose a remote with :pull_requests <remote>")?
} else {
self.remotes
.iter()
.find(|remote| remote.name == name)
.ok_or_else(|| format!("Unknown configured remote: {name}"))?
};
let mut config = git_command(&self.store);
config.args([
"config",
"--get",
&format!("remote.{}.vamp-forge", remote.name),
]);
let output = run(config, vec![], &self.cancelled)?;
let provider = if output.code == Some(1) {
None
} else {
Some(text(checked(output)?)?.trim().to_owned())
};
Ok((remote.name.clone(), remote.url.clone(), provider))
}
pub fn pull_request_page(
&mut self,
name: &str,
closed: bool,
page: u32,
) -> Result<PullRequestPage, String> {
let (remote, url, provider) = self.pull_request_remote(name)?;
let page = crate::forge::page(
&url,
provider.as_deref(),
closed,
page,
&self.root,
&self.cancelled,
)
.map_err(|error| error.replace("remote.<name>", &format!("remote.{remote}")))?;
Ok(PullRequestPage {
kind: self.kind,
remote: remote.clone(),
has_more: page.has_more,
entries: page
.entries
.into_iter()
.map(|pull| {
(
Revision {
target: RevisionTarget::PullRequest {
remote: remote.clone(),
number: pull.number,
},
label: format!("#{} {}", pull.number, pull.title),
description: pull.description,
category: if pull.closed {
Category::Closed
} else {
Category::Open
},
},
pull.created_at,
)
})
.collect(),
})
}
pub fn resolve_pull_request(
&mut self,
revision: Revision,
) -> Result<(Revision, Revision), String> {
let RevisionTarget::PullRequest { remote, number } = &revision.target else {
return Err("Select a pull request".into());
};
let (remote, url, provider) = self.pull_request_remote(remote)?;
let pull = crate::forge::resolve(
&url,
provider.as_deref(),
*number,
&self.root,
&self.cancelled,
)
.map_err(|error| error.replace("remote.<name>", &format!("remote.{remote}")))?;
let revisions = pull.revisions?;
let before = Revision {
target: RevisionTarget::RemoteCommit {
remote: remote.clone(),
reference: revisions.base_ref.clone(),
id: revisions.base_sha,
},
label: revisions.base_ref,
description: String::new(),
category: revision.category,
};
let after = Revision {
target: RevisionTarget::RemoteCommit {
remote,
reference: revisions.head_ref,
id: revisions.head_sha,
},
..revision
};
Ok((before, after))
}
fn resolve(&mut self, target: &RevisionTarget) -> Result<(PathBuf, String), String> {
let id = match target {
RevisionTarget::Commit(id) => {
RevisionTarget::Commit(id) | RevisionTarget::RemoteCommit { id, .. } => {
if !matches!(id.len(), 40 | 64)
|| (self.kind == RepoKind::Jujutsu && id.len() != 40)
|| !id.bytes().all(|b| b.is_ascii_hexdigit())
@@ -1560,7 +1676,9 @@ impl Repository {
})?
.clone(),
RevisionTarget::Working => return Err("Working checkout is not a commit".into()),
RevisionTarget::FetchRemote(_) | RevisionTarget::ListRemote(_) => {
RevisionTarget::FetchRemote(_)
| RevisionTarget::ListRemote(_)
| RevisionTarget::PullRequest { .. } => {
return Err("Select a revision, not a history loader".into());
}
};
@@ -1573,6 +1691,61 @@ impl Repository {
return Ok(resolved);
}
}
if let RevisionTarget::RemoteCommit {
remote, reference, ..
} = target
{
if !reference.starts_with("refs/") {
return Err("Invalid pull request reference".into());
}
self.git(&self.store, &["check-ref-format", reference])?;
self.discover_remotes()?;
let url = self
.remotes
.iter()
.find(|entry| &entry.name == remote)
.ok_or_else(|| format!("Unknown configured remote: {remote}"))?
.url
.clone();
self.ensure_private()?;
let store = &self.private.as_ref().unwrap().1;
// Closed PR refs can disappear; servers may still serve their immutable commits.
if self
.git(
store,
&[
"fetch",
"--no-tags",
"--no-write-fetch-head",
"--",
&url,
&format!("+{id}:refs/vamp-pull-request/selected"),
],
)
.is_err()
{
self.git(
store,
&[
"fetch",
"--no-tags",
"--no-write-fetch-head",
"--",
&url,
&format!("+{reference}:refs/vamp-pull-request/selected"),
],
)
.map_err(|error| {
format!("Pull request commit unavailable; reopen :pull_requests: {error}")
})?;
}
if let Some(resolved) = self.resolve_stored(&id)? {
return Ok(resolved);
}
return Err(
"Pull request changed or its commit is unavailable; reopen :pull_requests".into(),
);
}
Err(format!(
"Commit {id} is unavailable locally or in loaded remote history"
))
@@ -1595,65 +1768,29 @@ impl Repository {
}
Ok(None)
}
fn conflicted_snapshot(&self, root: &Path, id: &str) -> Result<Snapshot, String> {
fn conflicted_snapshot(&self, root: &Path, store: &Path, id: &str) -> Result<Snapshot, String> {
let revset = format!("commit_id({})", serde_json::to_string(id).unwrap());
let template = r#""[" ++ json(path) ++ "," ++ json(file_type) ++ "," ++ json(executable) ++ "," ++ json(conflict) ++ "]\n""#;
let template = r#""[" ++ json(path) ++ "," ++ json(conflict) ++ "]\n""#;
let entries = text(self.jj(root, &["file", "list", "-r", &revset, "-T", template])?)?;
let mut blobs = self.git_snapshot(store, id)?;
let mut snapshot = BTreeMap::new();
let mut total = 0;
for entry in entries.lines() {
self.check_cancelled()?;
let (path, kind, executable, conflict): (String, String, bool, bool) =
serde_json::from_str(entry)
let (path, conflict): (String, bool) = serde_json::from_str(entry)
.map_err(|e| format!("Cannot decode Jujutsu conflict entry: {e}"))?;
let path_buf = PathBuf::from(&path);
safe_path(&path_buf)?;
let mut notice = conflict.then(|| {
"Unresolved Jujutsu conflict: showing CLI-rendered conflict projection".to_owned()
});
let mode = if kind == "symlink" {
"120000"
} else if kind == "git-submodule" {
"160000"
} else if executable {
"100755"
} else {
"100644"
};
let bytes = if matches!(kind.as_str(), "file" | "symlink" | "conflict") {
// Use an exact fileset and jj's own conflict materialization.
let pattern = jj_file_pattern(&path);
self.jj(
root,
&["file", "show", "-r", &revset, "-T", "", "--", &pattern],
)?
} else {
notice = Some(format!(
"Unsupported {kind} content in a conflicted revision; nested contents are not traversed"
));
vec![]
};
if bytes.len() > FILE_LIMIT {
return Err(format!(
"{} exceeds the 16 MiB comparison file limit",
path_buf.display()
));
}
total += bytes.len();
if total > OUTPUT_LIMIT {
return Err(
"Jujutsu conflict projection exceeds the 64 MiB comparison limit".into(),
let path = PathBuf::from(path);
safe_path(&path)?;
// jj's logical file list excludes its private merge-tree encoding.
let mut file = blobs
.remove(&path)
.ok_or_else(|| format!("Missing Jujutsu tree entry: {}", path.display()))?;
if conflict {
file.projection = Some((root.to_path_buf(), id.into()));
file.notice = Some(
"Unresolved Jujutsu conflict: showing CLI-rendered conflict projection".into(),
);
}
snapshot.insert(
path_buf,
FileData {
mode: mode.into(),
bytes,
object: None,
notice,
},
);
snapshot.insert(path, file);
}
Ok(snapshot)
}
@@ -1680,9 +1817,13 @@ impl Repository {
],
)?)?;
if conflict.trim() == "true" {
return self.conflicted_snapshot(root, id);
return self.conflicted_snapshot(root, store, id);
}
}
self.git_snapshot(store, id)
}
fn git_snapshot(&self, store: &Path, id: &str) -> Result<Snapshot, String> {
let output = self.git(store, &["ls-tree", "-r", "-z", "--full-tree", id])?;
let mut snapshot = BTreeMap::new();
for record in output.split(|b| *b == 0).filter(|r| !r.is_empty()) {
@@ -1717,6 +1858,7 @@ impl Repository {
mode: fields[0].into(),
bytes: vec![],
object,
projection: None,
notice,
},
);
@@ -1727,6 +1869,7 @@ impl Repository {
fn materialize(&self, snapshot: &mut Snapshot) -> Result<(), String> {
let stores: BTreeSet<_> = snapshot
.values()
.filter(|file| file.projection.is_none())
.filter_map(|file| file.object.as_ref().map(|(store, _)| store.clone()))
.collect();
for store in stores {
@@ -1734,6 +1877,7 @@ impl Repository {
for file in snapshot.values() {
if let Some((source, id)) = &file.object
&& source == &store
&& file.projection.is_none()
{
request.extend_from_slice(id.as_bytes());
request.push(b'\n');
@@ -1744,7 +1888,8 @@ impl Repository {
let data = checked(run(cmd, request, &self.cancelled)?)?;
let mut offset = 0;
for (path, file) in snapshot.iter_mut() {
if file
if file.projection.is_some()
|| file
.object
.as_ref()
.is_none_or(|(source, _)| source != &store)
@@ -1780,6 +1925,33 @@ impl Repository {
offset += size + 1;
}
}
let mut total = 0;
for (path, file) in snapshot.iter_mut() {
let Some((root, id)) = &file.projection else {
continue;
};
let revset = format!("commit_id({})", serde_json::to_string(id).unwrap());
let pattern = jj_file_pattern(path.to_str().ok_or("Non-UTF-8 Jujutsu conflict path")?);
let bytes = self.jj(
root,
&["file", "show", "-r", &revset, "-T", "", "--", &pattern],
)?;
if bytes.len() > FILE_LIMIT {
return Err(format!(
"{} exceeds the 16 MiB comparison file limit",
path.display()
));
}
total += bytes.len();
if total > OUTPUT_LIMIT {
return Err(
"Jujutsu conflict projection exceeds the 64 MiB comparison limit".into(),
);
}
file.bytes = bytes;
file.object = None;
file.projection = None;
}
Ok(())
}
fn working_snapshot(&self, buffers: &[(PathBuf, String)]) -> Result<Snapshot, String> {
@@ -1949,6 +2121,7 @@ impl Repository {
mode: "160000".into(),
bytes: vec![],
object: None,
projection: None,
notice: Some(notice),
},
);
@@ -2028,6 +2201,7 @@ impl Repository {
mode,
bytes,
object: None,
projection: None,
notice,
},
);
@@ -2322,8 +2496,8 @@ impl Repository {
) -> Result<Comparison, String> {
let left = &mut prepared.left;
let right = &mut prepared.right;
// Frozen checkout/conflict bytes have no object identity. Compare them in a
// single batch, while immutable tree/tree comparisons need only object IDs.
// Frozen checkout bytes have no object identity. Compare them in a single
// batch; immutable blobs and deferred conflict projections retain IDs.
let frozen = |file: &FileData| {
file.object.is_none() && matches!(file.mode.as_str(), "100644" | "100755" | "120000")
};
@@ -2456,10 +2630,12 @@ impl Repository {
let mut right = BTreeMap::new();
let before = prepared.left.get(before_path);
let after = prepared.right.get(path);
if let Some(data) = before.filter(|data| data.object.is_some()) {
if let Some(data) = before.filter(|data| data.object.is_some() || data.projection.is_some())
{
left.insert(before_path.clone(), clone_file_data(data));
}
if let Some(data) = after.filter(|data| data.object.is_some()) {
if let Some(data) = after.filter(|data| data.object.is_some() || data.projection.is_some())
{
right.insert(path.clone(), clone_file_data(data));
}
self.materialize(&mut left)?;
@@ -2560,6 +2736,7 @@ fn clone_file_data(file: &FileData) -> FileData {
mode: file.mode.clone(),
bytes: file.bytes.clone(),
object: file.object.clone(),
projection: file.projection.clone(),
notice: file.notice.clone(),
}
}
+1527
View File
File diff suppressed because it is too large Load Diff
+139 -3
View File
@@ -3,6 +3,7 @@ use crate::editor::{Context, Effect, Feature};
use crate::plugin::{DocumentEvent, PluginEvent, PluginUpdate, UpdateEffect};
use serde::{Deserialize, Serialize};
use std::io::{self, BufRead, BufReader, Write};
use std::num::NonZeroU8;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender};
@@ -48,6 +49,8 @@ pub struct Config {
pub highlight_trailing_whitespace: bool,
pub whitespace_characters: BTreeMap<char, char>,
pub whitespace_scope: crate::editor::WhitespaceScope,
pub tab_spaces: Option<NonZeroU8>,
pub language_tab_spaces: BTreeMap<String, Option<NonZeroU8>>,
pub diff_context_lines: usize,
pub diff_expand_lines: usize,
pub diff_view_mode: crate::diff::ViewMode,
@@ -97,6 +100,8 @@ impl Default for Config {
highlight_trailing_whitespace: false,
whitespace_characters: BTreeMap::new(),
whitespace_scope: crate::editor::WhitespaceScope::All,
tab_spaces: None,
language_tab_spaces: BTreeMap::new(),
diff_context_lines: 10,
diff_expand_lines: 15,
diff_view_mode: crate::diff::ViewMode::Split,
@@ -133,6 +138,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
highlight_trailing_whitespace,
whitespace_characters,
whitespace_scope,
tab_spaces,
language_tab_spaces,
diff_context_lines,
diff_expand_lines,
diff_view_mode,
@@ -170,6 +177,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.highlight_trailing_whitespace = highlight_trailing_whitespace;
config.whitespace_characters = whitespace_characters;
config.whitespace_scope = whitespace_scope;
config.tab_spaces = tab_spaces;
config.language_tab_spaces = language_tab_spaces;
config.diff_context_lines = diff_context_lines;
config.diff_expand_lines = diff_expand_lines;
config.diff_view_mode = diff_view_mode;
@@ -507,6 +516,9 @@ struct ConfigFrame {
#[serde(deserialize_with = "whitespace_characters")]
whitespace_characters: BTreeMap<char, char>,
whitespace_scope: crate::editor::WhitespaceScope,
#[serde(deserialize_with = "Option::deserialize")]
tab_spaces: Option<NonZeroU8>,
language_tab_spaces: BTreeMap<String, Option<NonZeroU8>>,
#[serde(deserialize_with = "diff_context_lines")]
diff_context_lines: usize,
#[serde(deserialize_with = "diff_expand_lines")]
@@ -986,6 +998,125 @@ mod tests {
}
}
#[test]
fn tab_spaces_follow_active_language_and_preserve_native_edit_semantics() {
let fixture = Fixture::new();
let config = fixture.file(
"config.rb",
r#"
Vamp.configure do |c|
c.tab_spaces = 4
c.language_tab_spaces.merge!("ruby" => 2, "makefile" => nil, "json" => 1, "yaml" => 255, "typescriptreact" => 5)
c.language_tab_spaces["cpp"] = 3
end
"#,
);
let widest = " ".repeat(255);
for (name, prefix, inserted) in [
("default.rs", "", "\t"),
("buffer.rs", "", " "),
("buffer.rb", "", " "),
("Gemfile", "", " "),
("script", "#!/usr/bin/env ruby\n", " "),
("Makefile", "", "\t"),
("plain.txt", "", " "),
("buffer.json", "", " "),
("buffer.yaml", "", widest.as_str()),
("buffer.cpp", "", " "),
("buffer.tsx", "", " "),
] {
let source = format!("{prefix}αx\nβy");
let path = fixture.file(name, &source);
let config = (name != "default.rs").then_some(config.as_path());
let mut session = EditorSession::open(Some(path), vec![], config).unwrap();
assert!(session.editor().startup_diagnostic().is_none());
if !prefix.is_empty() {
session.handle(Input::Char('j'));
}
session.handle(Input::Ctrl('e'));
for input in [
Input::Char('l'),
Input::Char('i'),
Input::Char('\t'),
Input::Ctrl('i'),
Input::Escape,
] {
session.handle(input);
}
let expected = format!("{prefix}α{inserted}{inserted}x\nβ{inserted}{inserted}y");
assert_eq!(session.editor().context().text, expected, "{name}");
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, source, "{name}");
session.handle(Input::Char('U'));
assert_eq!(session.editor().context().text, expected, "{name}");
session.handle(Input::Char('u'));
session.handle(Input::Char('.'));
assert_eq!(session.editor().context().text, expected, "{name}");
session.handle(Input::Char('i'));
session.handle(Input::End);
session.handle(Input::Paste("\t".into()));
assert_eq!(
session.editor().context().text,
format!("{prefix}α{inserted}{inserted}x\t\nβ{inserted}{inserted}y\t"),
"{name}"
);
}
}
#[test]
fn tab_spaces_reject_invalid_widths_and_language_maps() {
let fixture = Fixture::new();
for value in ["0", "-1", "256", "1.5", "true", r#""4""#, "[]", "{}"] {
for assignment in [
format!("c.tab_spaces = {value}"),
format!(r#"c.language_tab_spaces = {{ "ruby" => {value} }}"#),
format!(r#"c.language_tab_spaces.merge!("ruby" => {value})"#),
format!(r#"c.language_tab_spaces["ruby"] = {value}"#),
] {
let config = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |c| {assignment} }}"),
);
let error = load_config(&config).unwrap_err();
assert!(
error.contains("tab_spaces must be nil or an Integer"),
"{assignment}: {error}"
);
}
}
for value in [
"nil",
"[]",
r#"{ "" => 2 }"#,
r#"{ :ruby => 2 }"#,
r#"{ "\n" => 2 }"#,
] {
let config = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |c| c.language_tab_spaces = {value} }}"),
);
assert!(load_config(&config).is_err(), "{value}");
}
}
#[test]
fn language_tab_space_mutations_reject_invalid_keys_before_serialization() {
let fixture = Fixture::new();
for key in [r#""""#, ":ruby", r#""\n""#, "7"] {
for assignment in [
format!("c.language_tab_spaces.merge!({key} => 2)"),
format!("c.language_tab_spaces[{key}] = 2"),
] {
let config = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |c| {assignment} }}"),
);
let error = load_config(&config).unwrap_err();
assert!(error.contains("Tab language"), "{assignment}: {error}");
}
}
}
#[test]
fn actual_ruby_dsl_context_queued_effects_and_output_isolation() {
let fixture = Fixture::new();
@@ -1274,9 +1405,9 @@ end
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
}
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","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","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","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":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
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","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
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","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
}
@@ -1356,6 +1487,7 @@ end
"highlight_trailing_whitespace": false,
"whitespace_characters": {},
"whitespace_scope": "all",
"tab_spaces": null, "language_tab_spaces": {},
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
@@ -1494,6 +1626,7 @@ end"#,
"file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true,
"git_gutter": "bar", "scroll_distance": 0, "highlight_trailing_whitespace": false,
"whitespace_characters": {}, "whitespace_scope": "all",
"tab_spaces": null, "language_tab_spaces": {},
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
@@ -1755,6 +1888,7 @@ end
"highlight_trailing_whitespace": false,
"whitespace_characters": {},
"whitespace_scope": "all",
"tab_spaces": null, "language_tab_spaces": {},
"popup": {"command": {}, "search": {}},
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
@@ -1806,6 +1940,7 @@ end
"highlight_trailing_whitespace": false,
"whitespace_characters": {},
"whitespace_scope": "all",
"tab_spaces": null, "language_tab_spaces": {},
"top_bar": {}, "bottom_bar": {}, "labels": {},
"popup": {"command": {}, "search": {}},
"diff_context_lines": 10, "diff_expand_lines": 15,
@@ -2237,6 +2372,7 @@ end"#,
"highlight_trailing_whitespace": false,
"whitespace_characters": {},
"whitespace_scope": "all",
"tab_spaces": null, "language_tab_spaces": {},
"top_bar": {}, "bottom_bar": {}, "labels": {},
"popup": {"command": {}, "search": {}},
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
+41
View File
@@ -120,6 +120,9 @@ module Vamp
# @return [Hash{String => Boolean, Hash{String, Symbol => Object}}] language presets/options
attr_reader :lsp
# @return [Hash{String => Integer, nil}] LSP-style language IDs to Tab widths
attr_reader :language_tab_spaces
# @return [Vamp::BarConfig]
attr_reader :bar
@@ -145,6 +148,8 @@ module Vamp
@highlight_trailing_whitespace = false
@whitespace_characters = {}
@whitespace_scope = "all"
@tab_spaces = nil
@language_tab_spaces = {}
@diff_context_lines = 10
@diff_expand_lines = 15
@diff_view_mode = "split"
@@ -254,6 +259,18 @@ module Vamp
@whitespace_scope = scope
end
# @param spaces [Integer, nil] spaces per Tab, or nil for a literal tab
# @return [Integer, nil]
def tab_spaces=(spaces)
@tab_spaces = tab_spaces!(spaces)
end
# @param languages [Hash{String => Integer, nil}] LSP-style language IDs to Tab widths
# @return [Hash{String => Integer, nil}]
def language_tab_spaces=(languages)
@language_tab_spaces = language_tab_spaces!(languages)
end
# @param lines [Integer] nonnegative context lines around each diff change
# @return [Integer]
def diff_context_lines=(lines)
@@ -329,6 +346,7 @@ module Vamp
"highlight_trailing_whitespace" => @highlight_trailing_whitespace,
"whitespace_characters" => @whitespace_characters,
"whitespace_scope" => @whitespace_scope,
"tab_spaces" => @tab_spaces, "language_tab_spaces" => language_tab_spaces!(@language_tab_spaces),
"top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels,
"popup" => { "command" => @popup.command, "search" => @popup.search },
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
@@ -337,6 +355,29 @@ module Vamp
end
private
# @param spaces [Integer, nil]
# @return [Integer, nil]
def tab_spaces!(spaces)
unless spaces.nil? || (spaces.is_a?(Integer) && spaces.between?(1, 255))
raise ArgumentError, "tab_spaces must be nil or an Integer between 1 and 255"
end
spaces
end
# @param languages [Hash{String => Integer, nil}]
# @return [Hash{String => Integer, nil}]
def language_tab_spaces!(languages)
raise TypeError, "language_tab_spaces must be a Hash" unless languages.is_a?(Hash)
languages.to_h do |language, spaces|
language = nonempty_string!(language, "Tab language")
raise ArgumentError, "Tab language must not contain control characters" if language.match?(/[[:cntrl:]]/)
[language, tab_spaces!(spaces)]
end
end
# @param value [Integer]
# @param label [String]
# @param minimum [Integer]
+578 -33
View File
@@ -14,7 +14,10 @@ use std::{
use crate::{
blame::BlameWorker,
diff::{Focus, Operation as DiffOperation, Outcome as DiffOutcome},
editor::{BlameSource, Cursor, Editor, Effect, Input, Invocation, Mode},
editor::{
BlameSource, Cursor, Editor, Effect, Input, Invocation, Mode, ResolverOutcome,
ResolverRequest,
},
lsp::Lsp,
plugin::{
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
@@ -26,6 +29,219 @@ use crate::{
type DiffResult = (u64, Result<DiffOutcome, String>);
struct ResolverWorker {
requests: Option<Sender<ResolverRequest>>,
results: Receiver<ResolverOutcome>,
stopped: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
impl ResolverWorker {
fn start() -> Result<Self, String> {
let (requests, incoming) = mpsc::channel::<ResolverRequest>();
let (outgoing, results) = mpsc::channel();
let stopped = Arc::new(AtomicBool::new(false));
let stop = Arc::clone(&stopped);
let thread = thread::Builder::new()
.name("vamp-resolve".into())
.spawn(move || {
while let Ok(request) = incoming.recv() {
if stop.load(Ordering::Acquire) {
break;
}
let cancelled = || stop.load(Ordering::Acquire);
let outcome = match request {
ResolverRequest::Load { tab, root } => ResolverOutcome::Loaded {
tab,
result: crate::resolve::load(&root, &cancelled),
},
ResolverRequest::Save {
tab,
file,
buffer,
workspace,
resolution,
quit,
} => ResolverOutcome::Saved {
tab,
file,
buffer,
quit,
result: crate::resolve::save(&workspace, file, resolution, &cancelled),
},
};
if outgoing.send(outcome).is_err() {
break;
}
}
})
.map_err(|error| format!("Cannot start resolver worker: {error}"))?;
Ok(Self {
requests: Some(requests),
results,
stopped,
thread: Some(thread),
})
}
}
impl Drop for ResolverWorker {
fn drop(&mut self) {
self.stopped.store(true, Ordering::Release);
self.requests.take();
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
#[derive(Clone, Copy)]
struct PullRequestJob {
page: u32,
busy: bool,
complete: bool,
failed: bool,
retry: u64,
}
struct PullRequestWorker {
id: u64,
requests: Vec<Sender<u32>>,
threads: Vec<JoinHandle<()>>,
cancelled: Arc<AtomicBool>,
jobs: [PullRequestJob; 2],
priority: usize,
}
impl PullRequestWorker {
fn start(
id: u64,
root: &Path,
remote: String,
outgoing: Sender<DiffResult>,
) -> Result<Self, String> {
let cancelled = Arc::new(AtomicBool::new(false));
let fetches = std::array::from_fn(|index| {
let root = root.to_path_buf();
let remote = remote.clone();
let cancelled = Arc::clone(&cancelled);
let mut repository = None;
move |page| {
if repository.is_none() {
repository = Some(Repository::open(&root, Arc::clone(&cancelled))?);
}
repository
.as_mut()
.expect("repository opened")
.pull_request_page(&remote, index == 1, page)
}
});
Self::start_with(id, outgoing, cancelled, fetches)
}
fn start_with(
id: u64,
outgoing: Sender<DiffResult>,
cancelled: Arc<AtomicBool>,
fetches: [impl FnMut(u32) -> Result<crate::diff::PullRequestPage, String> + Send + 'static;
2],
) -> Result<Self, String> {
let mut worker = Self {
id,
requests: Vec::with_capacity(2),
threads: Vec::with_capacity(2),
cancelled,
jobs: [PullRequestJob {
page: 1,
busy: false,
complete: false,
failed: false,
retry: 0,
}; 2],
priority: 0,
};
for (index, mut fetch) in fetches.into_iter().enumerate() {
let (requests, incoming) = mpsc::channel();
let outgoing = outgoing.clone();
let cancelled = Arc::clone(&worker.cancelled);
let thread = thread::Builder::new()
.name(format!("vamp-pull-requests-{index}"))
.spawn(move || {
while let Ok(page) = incoming.recv() {
if cancelled.load(Ordering::Acquire) {
break;
}
let result = fetch(page);
if cancelled.load(Ordering::Acquire)
|| outgoing
.send((
id,
Ok(DiffOutcome::PullRequestPage {
closed: index == 1,
result,
}),
))
.is_err()
{
break;
}
}
})
.map_err(|error| format!("Cannot start pull request worker: {error}"))?;
worker.requests.push(requests);
worker.threads.push(thread);
}
worker.schedule()?;
Ok(worker)
}
fn update(&mut self, category: usize, retries: [u64; 2]) -> Result<(), String> {
self.priority = usize::from(category == 1);
for (job, retry) in self.jobs.iter_mut().zip(retries) {
if job.retry != retry {
job.retry = retry;
job.failed = false;
}
}
self.schedule()
}
fn complete(&mut self, closed: bool, result: &Result<crate::diff::PullRequestPage, String>) {
let job = &mut self.jobs[usize::from(closed)];
job.busy = false;
match result {
Ok(page) => {
job.complete = !page.has_more;
job.page += 1;
}
Err(_) => job.failed = true,
}
}
fn schedule(&mut self) -> Result<(), String> {
for index in [self.priority, 1 - self.priority] {
let job = &mut self.jobs[index];
if !job.busy && !job.complete && !job.failed {
self.requests[index]
.send(job.page)
.map_err(|_| "Pull request worker stopped unexpectedly")?;
job.busy = true;
}
}
Ok(())
}
}
impl Drop for PullRequestWorker {
fn drop(&mut self) {
self.cancelled.store(true, Ordering::Release);
self.requests.clear();
for thread in self.threads.drain(..) {
let _ = thread.join();
}
}
}
struct DiffWorker {
requests: Option<Sender<(u64, DiffOperation)>>,
results: Option<Receiver<DiffResult>>,
@@ -33,13 +249,18 @@ struct DiffWorker {
stopped: Arc<AtomicBool>,
cancelled_request: Arc<AtomicU64>,
thread: Option<JoinHandle<()>>,
root: PathBuf,
outgoing: Sender<DiffResult>,
pull_requests: Option<PullRequestWorker>,
pending: Option<u64>,
}
impl DiffWorker {
fn start(root: PathBuf) -> Result<Self, String> {
let (requests, incoming) = mpsc::channel::<(u64, DiffOperation)>();
let (outgoing, results) = mpsc::sync_channel(1);
let (outgoing, results) = mpsc::channel();
let sender = outgoing.clone();
let worker_root = root.clone();
let cancelled = Arc::new(AtomicBool::new(false));
let stopped = Arc::new(AtomicBool::new(false));
let cancelled_request = Arc::new(AtomicU64::new(0));
@@ -63,13 +284,23 @@ impl DiffWorker {
}
let result = (|| {
if repository.is_none() {
repository = Some(Repository::open(&root, Arc::clone(&cancellation))?);
repository =
Some(Repository::open(&worker_root, Arc::clone(&cancellation))?);
}
let repository = repository.as_mut().expect("repository opened");
match operation {
DiffOperation::Discover => {
repository.catalog().map(DiffOutcome::Catalog)
}
DiffOperation::PullRequests(_) => {
unreachable!("listing uses category workers")
}
DiffOperation::ResolvePullRequest(revision) => repository
.resolve_pull_request(revision)
.map(|(before, after)| DiffOutcome::ResolvedPullRequest {
before,
after,
}),
DiffOperation::ListRemote(remote) => {
repository.list_remote(&remote).map(DiffOutcome::Catalog)
}
@@ -116,15 +347,29 @@ impl DiffWorker {
stopped,
cancelled_request,
thread: Some(thread),
root,
outgoing: sender,
pull_requests: None,
pending: None,
})
}
fn request(&mut self, id: u64, operation: DiffOperation) -> Result<(), String> {
self.pull_requests = None;
if let Some(previous) = self.pending.take() {
self.cancelled_request.store(previous, Ordering::Release);
self.cancelled.store(true, Ordering::Release);
}
if let DiffOperation::PullRequests(remote) = operation {
self.pull_requests = Some(PullRequestWorker::start(
id,
&self.root,
remote,
self.outgoing.clone(),
)?);
self.pending = Some(id);
return Ok(());
}
self.requests
.as_ref()
.expect("live diff worker has a sender")
@@ -138,6 +383,7 @@ impl DiffWorker {
impl Drop for DiffWorker {
fn drop(&mut self) {
self.stopped.store(true, Ordering::Release);
self.pull_requests = None;
self.cancelled.store(true, Ordering::Release);
self.requests.take();
self.results.take();
@@ -190,6 +436,7 @@ pub struct EditorSession {
pending_completion: bool,
hover_owner: Option<String>,
diff_workers: BTreeMap<usize, DiffWorker>,
resolver_worker: Option<ResolverWorker>,
blame_worker: Option<BlameWorker>,
blame_source: Option<BlameSource>,
blame_lines: Option<Vec<Option<Arc<crate::blame::LineBlame>>>>,
@@ -199,7 +446,7 @@ pub struct EditorSession {
gutter_request_id: u64,
gutter_pending: bool,
last_gutter_refresh: Option<Instant>,
picker_source: Option<(usize, PathBuf)>,
picker_source: Option<PathBuf>,
battery: Option<crate::battery::Battery>,
battery_enabled: bool,
last_file_refresh: Instant,
@@ -260,6 +507,7 @@ impl EditorSession {
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_tab_spaces(config.tab_spaces, config.language_tab_spaces);
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
if let Err(error) =
@@ -309,6 +557,7 @@ impl EditorSession {
pending_completion: false,
hover_owner: None,
diff_workers: BTreeMap::new(),
resolver_worker: None,
blame_worker: None,
blame_source: None,
blame_lines: None,
@@ -362,6 +611,25 @@ impl EditorSession {
self.synchronize_documents();
}
pub(crate) fn set_resolver_viewport(
&mut self,
top: usize,
left_before: usize,
left_after: usize,
) {
self.editor
.set_resolver_viewport(top, left_before, left_after);
}
pub(crate) fn scroll_resolver(&mut self, focus: Focus, down: bool) {
self.editor.scroll_resolver_diff(focus, down);
}
pub(crate) fn scroll_resolver_horizontal(&mut self, focus: Focus, right: bool, width: usize) {
self.editor
.scroll_resolver_diff_horizontal(focus, right, width);
}
pub fn handle(&mut self, input: Input) {
if input == Input::Ctrl('r')
&& self
@@ -379,6 +647,7 @@ impl EditorSession {
self.synchronize_documents();
}
self.start_diff_request();
self.start_resolver_requests();
}
pub fn execute(&mut self, command: &str) {
@@ -390,6 +659,67 @@ impl EditorSession {
self.synchronize_documents();
}
self.start_diff_request();
self.start_resolver_requests();
}
fn start_resolver_requests(&mut self) {
while let Some(request) = self.editor.take_resolver_request() {
if self.resolver_worker.is_none() {
match ResolverWorker::start() {
Ok(worker) => self.resolver_worker = Some(worker),
Err(error) => {
self.fail_resolver_request(request, error);
continue;
}
}
}
if let Err(error) = self
.resolver_worker
.as_ref()
.unwrap()
.requests
.as_ref()
.unwrap()
.send(request)
{
self.fail_resolver_request(error.0, "Resolver worker stopped unexpectedly".into());
self.resolver_worker = None;
}
}
}
fn fail_resolver_request(&mut self, request: ResolverRequest, error: String) {
let outcome = match request {
ResolverRequest::Load { tab, .. } => ResolverOutcome::Loaded {
tab,
result: Err(error),
},
ResolverRequest::Save {
tab,
file,
buffer,
quit,
..
} => ResolverOutcome::Saved {
tab,
file,
buffer,
quit,
result: Err(error),
},
};
self.editor.apply_resolver_outcome(outcome);
}
fn poll_resolver(&mut self) -> bool {
self.start_resolver_requests();
let mut changed = false;
if let Some(worker) = &self.resolver_worker {
while let Ok(outcome) = worker.results.try_recv() {
changed |= self.editor.apply_resolver_outcome(outcome);
}
}
changed
}
fn start_diff_request(&mut self) {
@@ -397,6 +727,15 @@ impl EditorSession {
let Some(diff) = self.editor.tab_diff(*tab) else {
return false;
};
if let Some(listing) = &mut worker.pull_requests {
if let Err(error) = listing.update(diff.category, diff.pull_request_retries) {
if let Some(id) = worker.pending.take() {
self.editor.apply_diff_result(id, Err(error));
}
return false;
}
return true;
}
if !diff.loading
&& !diff.refresh_pending()
&& let Some(id) = worker.pending.take()
@@ -443,12 +782,28 @@ impl EditorSession {
.try_recv();
match result {
Ok((id, result)) => {
if worker.pending == Some(id) {
if let Ok(DiffOutcome::PullRequestPage { closed, result }) = &result {
if let Some(listing) = worker
.pull_requests
.as_mut()
.filter(|listing| listing.id == id)
{
listing.complete(*closed, result);
}
} else if worker.pending == Some(id) {
worker.pending = None;
}
changed |= self.editor.apply_diff_result(id, result);
}
Err(mpsc::TryRecvError::Empty) => return true,
Err(mpsc::TryRecvError::Empty) => {
if let Some(listing) = &mut worker.pull_requests
&& let Err(error) = listing.schedule()
{
changed |= self.editor.apply_diff_result(listing.id, Err(error));
return false;
}
return true;
}
Err(mpsc::TryRecvError::Disconnected) => {
if let Some(id) = worker.pending.take() {
changed |= self.editor.apply_diff_result(
@@ -568,6 +923,7 @@ impl EditorSession {
}
fn synchronize_documents(&mut self) -> bool {
self.editor.refresh_resolver_comparison();
let blame_changed = self.poll_blame();
// Leaving a request's source invalidates it permanently, even if history returns there.
if let Some(requests) = self.requests.get_mut("core")
@@ -1072,15 +1428,8 @@ impl EditorSession {
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())
{
let picker_changed = self.picker_source.as_deref() != Some(self.editor.project_root());
if self.editor.should_quit() {
self.gutter_worker = None;
self.gutter_sources.clear();
self.gutter_pending = false;
@@ -1117,10 +1466,7 @@ impl EditorSession {
.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()));
self.picker_source = Some(self.editor.project_root().to_owned());
}
if sources_changed {
self.gutter_sources = self
@@ -1154,11 +1500,6 @@ impl EditorSession {
}
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),
@@ -1180,7 +1521,7 @@ impl EditorSession {
)
})
.collect();
let picker = self.picker_source.as_ref().map(|(_, root)| {
let picker = self.picker_source.as_ref().map(|root| {
let buffers = self
.gutter_sources
.iter()
@@ -1248,20 +1589,15 @@ impl EditorSession {
}
fn apply_picker_result(&mut self, id: u64, reply: crate::gutter::PickerChanges) -> bool {
let Some((tab, root)) = &self.picker_source else {
let Some(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)
|| *root != self.editor.project_root()
{
return false;
}
let tab = *tab;
let mut changed = false;
let changes = match reply.result {
Ok(changes) => changes,
@@ -1274,7 +1610,7 @@ impl EditorSession {
changed
| self
.editor
.set_picker_repository_changes(tab, &reply.root, changes)
.set_picker_repository_changes(&reply.root, changes)
}
pub(crate) fn refresh_gutter(&mut self) -> bool {
@@ -1420,6 +1756,7 @@ impl EditorSession {
changed |= self.poll_battery();
self.refresh_checkout_diffs_at(now);
changed |= self.poll_diff();
changed |= self.poll_resolver();
for update in self.lsp.poll() {
match self.apply_update("core", update) {
Ok(applied) => changed |= applied,
@@ -1481,6 +1818,135 @@ mod tests {
sync::atomic::{AtomicU64, Ordering},
};
#[test]
fn resolver_load_errors_return_through_real_session_without_touching_the_draft() {
let fixture = Fixture::new();
let path = fixture.file("buffer.txt", "disk\n");
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
session
.editor
.apply(vec![Effect::Replace("draft\n".into())])
.unwrap();
let buffer = session.editor.buffer_id();
session.execute("resolve");
assert!(session.editor.resolver().unwrap().loading);
let outcome = session
.resolver_worker
.as_ref()
.unwrap()
.results
.recv_timeout(Duration::from_secs(5))
.unwrap();
session.editor.apply_resolver_outcome(outcome);
assert!(!session.editor.resolver().unwrap().loading);
assert!(session.editor.resolver().unwrap().error.is_some());
session.execute("resolve.close");
assert!(
session
.editor
.documents()
.any(|document| document.id == buffer
&& document.dirty
&& document.lines == ["draft", ""])
);
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"disk\n"
);
}
#[test]
fn pull_request_pages_stream_concurrently_and_retry_only_the_failed_category() {
use crate::diff::{PullRequestPage, RepoKind};
let (outgoing, results) = mpsc::channel();
let (started, starts) = mpsc::channel();
let (open, open_gate) = mpsc::channel::<Result<bool, String>>();
let (closed, closed_gate) = mpsc::channel::<Result<bool, String>>();
let mut gates = [Some(open_gate), Some(closed_gate)];
let cancelled = Arc::new(AtomicBool::new(false));
let fetches = std::array::from_fn(|index| {
let gate = gates[index].take().unwrap();
let started = started.clone();
let cancelled = Arc::clone(&cancelled);
move |page| {
started.send((index, page)).unwrap();
let has_more = loop {
match gate.try_recv() {
Ok(result) => break result?,
Err(mpsc::TryRecvError::Empty) if !cancelled.load(Ordering::Acquire) => {
thread::yield_now()
}
Err(_) => return Err("cancelled".into()),
}
};
Ok(PullRequestPage {
kind: RepoKind::Git,
remote: "origin".into(),
entries: vec![],
has_more,
})
}
});
let mut worker = PullRequestWorker::start_with(7, outgoing, cancelled, fetches).unwrap();
let mut first = [
starts.recv_timeout(Duration::from_secs(2)).unwrap(),
starts.recv_timeout(Duration::from_secs(2)).unwrap(),
];
first.sort_unstable();
assert_eq!(first, [(0, 1), (1, 1)]);
let receive = |worker: &mut PullRequestWorker| {
let (id, result) = results.recv_timeout(Duration::from_secs(2)).unwrap();
assert_eq!(id, 7);
let Ok(DiffOutcome::PullRequestPage { closed, result }) = result else {
panic!("expected independently published page");
};
worker.complete(closed, &result);
(closed, result)
};
closed.send(Ok(true)).unwrap();
let (category, page) = receive(&mut worker);
assert!(category && page.unwrap().has_more);
// Open's first request remains blocked while the active Closed category advances.
worker.update(1, [0, 0]).unwrap();
assert_eq!(starts.recv_timeout(Duration::from_secs(2)).unwrap(), (1, 2));
closed.send(Err("late failure".into())).unwrap();
assert_eq!(receive(&mut worker).1.unwrap_err(), "late failure");
worker.update(1, [0, 0]).unwrap();
assert!(starts.try_recv().is_err());
worker.update(1, [0, 1]).unwrap();
assert_eq!(starts.recv_timeout(Duration::from_secs(2)).unwrap(), (1, 2));
closed.send(Ok(false)).unwrap();
assert!(!receive(&mut worker).1.unwrap().has_more);
open.send(Ok(false)).unwrap();
assert!(!receive(&mut worker).0);
worker.update(2, [0, 1]).unwrap();
assert!(starts.try_recv().is_err());
}
#[test]
fn closing_pull_request_listing_cancels_both_outstanding_reads() {
let (outgoing, _results) = mpsc::channel();
let (started, starts) = mpsc::channel();
let cancelled = Arc::new(AtomicBool::new(false));
let fetches = std::array::from_fn(|index| {
let started = started.clone();
let cancelled = Arc::clone(&cancelled);
move |_| {
started.send(index).unwrap();
while !cancelled.load(Ordering::Acquire) {
thread::yield_now();
}
Err("cancelled".into())
}
});
let worker =
PullRequestWorker::start_with(1, outgoing, Arc::clone(&cancelled), fetches).unwrap();
starts.recv_timeout(Duration::from_secs(2)).unwrap();
starts.recv_timeout(Duration::from_secs(2)).unwrap();
drop(worker);
assert!(cancelled.load(Ordering::Acquire));
}
#[test]
fn normal_picker_tracks_unsaved_text_and_head_with_line_gutter_disabled() {
use crate::diff::FileStatus;
@@ -1521,6 +1987,16 @@ mod tests {
session.handle(Input::Char(' '));
session.handle(Input::Char('o'));
};
let status = |session: &EditorSession| {
session
.editor
.normal_file_picker()
.unwrap()
.entries()
.find(|row| row.path == Path::new("buffer.txt"))
.unwrap()
.status
};
let ready = |session: &mut EditorSession, clock: Instant, expected| {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
@@ -1550,16 +2026,56 @@ mod tests {
thread::sleep(Duration::from_millis(5));
}
};
let settle = |session: &mut EditorSession| {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
session.poll_gutter_at(now);
if !session.gutter_pending {
break;
}
assert!(
Instant::now() < deadline,
"background file status did not settle"
);
thread::sleep(Duration::from_millis(5));
}
};
settle(&mut session);
open(&mut session);
assert_eq!(
status(&session),
Some(FileStatus::Modified),
"first open must reuse status loaded while the picker was closed",
);
ready(&mut session, now, Some(FileStatus::Modified));
session.handle(Input::Ctrl('w'));
settle(&mut session);
open(&mut session);
assert_eq!(
status(&session),
Some(FileStatus::Modified),
"reopening must retain completed status",
);
let request = session.gutter_request_id;
session.poll_gutter_at(now);
assert_eq!(
session.gutter_request_id, request,
"opening must not restart status work"
);
session.handle(Input::Ctrl('w'));
session.poll_gutter_at(now);
session
.editor
.apply(vec![Effect::Replace("base\n".into())])
.unwrap();
assert!(session.editor.dirty());
settle(&mut session);
open(&mut session);
assert_eq!(
status(&session),
None,
"closed-picker edits must refresh the cache"
);
ready(&mut session, now, None);
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
@@ -1568,11 +2084,40 @@ mod tests {
session.handle(Input::Ctrl('w'));
session.poll_gutter_at(now);
session.handle(Input::Char('u'));
settle(&mut session);
open(&mut session);
assert_eq!(status(&session), Some(FileStatus::Modified));
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);
assert!(!session.apply_picker_result(
session.gutter_request_id.wrapping_sub(1),
crate::gutter::PickerChanges {
root: fixture.0.clone(),
result: Ok(Vec::new()),
},
));
assert!(!session.apply_picker_result(
session.gutter_request_id,
crate::gutter::PickerChanges {
root: fixture.0.join("elsewhere"),
result: Ok(Vec::new()),
},
));
session.handle(Input::Ctrl('w'));
session.execute("tab.new");
open(&mut session);
assert_eq!(status(&session), None);
assert!(
session
.editor
.normal_file_picker()
.unwrap()
.entries()
.any(|row| row.path == Path::new("added.txt")
&& row.status == Some(FileStatus::Added))
);
}
#[test]
+9
View File
@@ -9,10 +9,18 @@ use tree_sitter::{
mod languages;
use languages::LanguageSpec;
pub(crate) const RUST_ID: &str = "rust";
pub(crate) const RUBY_ID: &str = "ruby";
pub(crate) const PYTHON_ID: &str = "python";
pub(crate) fn is_markdown_path(path: &Path) -> bool {
languages::detect(Some(path), None).is_some_and(|syntax| syntax.name == "Markdown")
}
pub(crate) fn language_id(path: Option<&Path>, first_line: Option<&str>) -> Option<&'static str> {
languages::detect(path, first_line).map(|syntax| syntax.id)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SyntaxKind {
Comment,
@@ -2116,6 +2124,7 @@ mod tests {
"((identifier) @function (#unsupported? @function))",
]
.map(|query| LanguageSpec {
id: language.id,
name: "invalid test grammar query",
language: language.language.clone(),
highlights: query,
+21
View File
@@ -3,6 +3,7 @@ use std::path::Path;
use std::sync::LazyLock;
pub(super) struct LanguageSpec {
pub id: &'static str,
pub name: &'static str,
pub language: tree_sitter::Language,
pub highlights: &'static str,
@@ -93,6 +94,7 @@ fn javascript_injections() -> &'static str {
}
fn spec(
id: &'static str,
name: &'static str,
language: tree_sitter::Language,
highlights: &'static str,
@@ -100,6 +102,7 @@ fn spec(
locals: &'static str,
) -> LanguageSpec {
LanguageSpec {
id,
name,
language,
highlights,
@@ -111,6 +114,7 @@ fn spec(
static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
[
spec(
super::RUST_ID,
"Rust",
tree_sitter_rust::LANGUAGE.into(),
tree_sitter_rust::HIGHLIGHTS_QUERY,
@@ -118,6 +122,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"",
),
spec(
super::RUBY_ID,
"Ruby",
tree_sitter_ruby::LANGUAGE.into(),
tree_sitter_ruby::HIGHLIGHTS_QUERY,
@@ -125,6 +130,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
tree_sitter_ruby::LOCALS_QUERY,
),
spec(
super::PYTHON_ID,
"Python",
tree_sitter_python::LANGUAGE.into(),
tree_sitter_python::HIGHLIGHTS_QUERY,
@@ -132,6 +138,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"",
),
spec(
"json",
"JSON",
tree_sitter_json::LANGUAGE.into(),
tree_sitter_json::HIGHLIGHTS_QUERY,
@@ -139,6 +146,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"",
),
spec(
"shellscript",
"Bash",
tree_sitter_bash::LANGUAGE.into(),
tree_sitter_bash::HIGHLIGHT_QUERY,
@@ -146,6 +154,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"",
),
spec(
"makefile",
"Makefile",
tree_sitter_make::LANGUAGE.into(),
tree_sitter_make::HIGHLIGHTS_QUERY,
@@ -153,6 +162,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"",
),
spec(
"markdown",
"Markdown",
tree_sitter_md::LANGUAGE.into(),
MD_HIGHLIGHTS.as_str(),
@@ -160,6 +170,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"",
),
spec(
"javascript",
"JavaScript",
tree_sitter_javascript::LANGUAGE.into(),
JS_HIGHLIGHTS.as_str(),
@@ -167,6 +178,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
tree_sitter_javascript::LOCALS_QUERY,
),
spec(
"typescript",
"TypeScript",
tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
TS_HIGHLIGHTS.as_str(),
@@ -174,6 +186,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
TS_LOCALS.as_str(),
),
spec(
"html",
"HTML",
tree_sitter_html::LANGUAGE.into(),
tree_sitter_html::HIGHLIGHTS_QUERY,
@@ -181,6 +194,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"",
),
spec(
"css",
"CSS",
tree_sitter_css::LANGUAGE.into(),
tree_sitter_css::HIGHLIGHTS_QUERY,
@@ -188,6 +202,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"",
),
spec(
"yaml",
"YAML",
tree_sitter_yaml::LANGUAGE.into(),
tree_sitter_yaml::HIGHLIGHTS_QUERY,
@@ -195,6 +210,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"",
),
spec(
"toml",
"TOML",
tree_sitter_toml_ng::LANGUAGE.into(),
tree_sitter_toml_ng::HIGHLIGHTS_QUERY,
@@ -202,6 +218,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"",
),
spec(
"c",
"C",
tree_sitter_c::LANGUAGE.into(),
tree_sitter_c::HIGHLIGHT_QUERY,
@@ -209,6 +226,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"",
),
spec(
"cpp",
"C++",
tree_sitter_cpp::LANGUAGE.into(),
CPP_HIGHLIGHTS.as_str(),
@@ -216,6 +234,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"",
),
spec(
"go",
"Go",
tree_sitter_go::LANGUAGE.into(),
tree_sitter_go::HIGHLIGHTS_QUERY,
@@ -223,6 +242,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
"",
),
spec(
"typescriptreact",
"TypeScript",
tree_sitter_typescript::LANGUAGE_TSX.into(),
TSX_HIGHLIGHTS.as_str(),
@@ -230,6 +250,7 @@ static LANGUAGES: LazyLock<[LanguageSpec; 18]> = LazyLock::new(|| {
TS_LOCALS.as_str(),
),
spec(
"markdown",
"Markdown inline",
tree_sitter_md::INLINE_LANGUAGE.into(),
tree_sitter_md::HIGHLIGHT_QUERY_INLINE,
+1956 -156
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -42,6 +42,62 @@ fn keys(editor: &mut Editor, text: &str) {
}
}
#[test]
fn visual_paste_replaces_selection_and_preserves_clipboard_and_undo() {
for paste in ['p', 'P'] {
for (yank, select, expected) in [
("vly", "j0vl", "XY\nXYc\nend"),
("vly", "j0lvh", "XY\nXYc\nend"),
("yy", "j0vl", "XY\nXY\nc\nend"),
("yy", "jV", "XY\nXY\nend"),
("vly", "jV", "XY\nXYend"),
] {
let fixture = Fixture::new();
let source = "XY\nabc\nend";
let mut editor = fixture.editor(source);
keys(&mut editor, yank);
keys(&mut editor, select);
editor.handle(Input::Char(paste));
assert_eq!(editor.context().text, expected, "{yank} {select} {paste}");
assert_eq!(editor.mode(), Mode::Normal);
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
keys(&mut editor, "U");
assert_eq!(editor.context().text, expected);
keys(
&mut editor,
if select.ends_with("vh") {
"u0l."
} else {
"u0."
},
);
assert_eq!(
editor.context().text,
expected,
"repeat: {yank} {select} {paste}"
);
keys(&mut editor, "u");
keys(&mut editor, select.trim_start_matches('j'));
editor.handle(Input::Char(paste));
assert_eq!(editor.context().text, expected, "clipboard stays unchanged");
}
}
}
#[test]
fn visual_paste_replaces_every_selected_range_in_one_undo_group() {
let fixture = Fixture::new();
let source = "XY\n界e\u{301}\n👩‍🚀z";
let mut editor = fixture.editor(source);
keys(&mut editor, "vlyj0vl");
editor.handle(Input::Ctrl('e'));
keys(&mut editor, "p");
assert_eq!(editor.context().text, "XY\nXY\nXY");
keys(&mut editor, "u");
assert_eq!(editor.context().text, source);
}
#[test]
fn whole_line_yank_pastes_above_or_below_the_target_and_keeps_indentation() {
let source = " \t界e\u{301}👩‍🚀\nother\nlast\n";
+173
View File
@@ -1053,6 +1053,7 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable
init_jj(&root, false);
fs::write(root.join("conflict"), "base\n").unwrap();
fs::write(root.join("ordinary"), "old\n").unwrap();
symlink("ordinary", root.join("unchanged-link")).unwrap();
jj(&root, &["describe", "-m", "base"]);
let base = jj(
&root,
@@ -1155,6 +1156,14 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable
&[],
)
.unwrap();
assert_eq!(
comparison
.files
.iter()
.map(|file| file.path.as_path())
.collect::<Vec<_>>(),
[Path::new("conflict"), Path::new("ordinary")]
);
let conflict = changed(&comparison, Path::new("conflict"));
assert_eq!(conflict.after, expected);
assert!(
@@ -1209,6 +1218,93 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable
assert_eq!(disk_state(&root), state);
}
#[test]
fn conflicted_catalog_preserves_symlinks_and_defers_unselected_contents() {
let fixture = Fixture::new();
let root = fixture.dir("lazy-conflict");
init_jj(&root, false);
fs::write(root.join("conflict"), "base\n").unwrap();
fs::write(root.join("unselected"), "old\n").unwrap();
symlink("unselected", root.join("unchanged-link")).unwrap();
jj(&root, &["describe", "-m", "base"]);
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new", &base]);
fs::write(root.join("conflict"), "left\n").unwrap();
fs::write(root.join("unselected"), vec![b'x'; 17 * 1024 * 1024]).unwrap();
jj(&root, &["describe", "-m", "left"]);
let left = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new", &base]);
fs::write(root.join("conflict"), "right\n").unwrap();
jj(&root, &["describe", "-m", "right"]);
let right = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new", &left, &right, "-m", "conflicted merge"]);
let merged = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
let state = disk_state(&root);
let mut repository = open(&root);
let comparison = repository
.compare(
1,
&revision(&base, RepoKind::Jujutsu),
&revision(&merged, RepoKind::Jujutsu),
ComparisonMode::BranchChanges,
&[],
)
.unwrap();
assert_eq!(
comparison
.files
.iter()
.map(|file| file.path.as_path())
.collect::<Vec<_>>(),
[Path::new("conflict"), Path::new("unselected")]
);
let conflict = repository.load_file(1, 0).unwrap();
assert_eq!(conflict.before, ["base"]);
let projection = jj(
&root,
&[
"--ignore-working-copy",
"file",
"show",
"-r",
&merged,
"-T",
"",
"--",
"conflict",
],
);
assert_eq!(conflict.after, projection.lines().collect::<Vec<_>>());
assert!(!conflict.hunks.is_empty());
assert!(repository.load_file(1, 1).is_err());
assert_eq!(disk_state(&root), state);
jj(&root, &["new", &merged]);
fs::write(root.join("extra"), "extra\n").unwrap();
jj(
&root,
&["describe", "-m", "unrelated change retaining conflict"],
);
let next = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
let comparison = repository
.compare(
2,
&revision(&merged, RepoKind::Jujutsu),
&revision(&next, RepoKind::Jujutsu),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert_eq!(
comparison
.files
.iter()
.map(|file| file.path.as_path())
.collect::<Vec<_>>(),
[Path::new("extra")]
);
}
#[test]
fn unsaved_overlays_are_working_after_only_and_cannot_follow_nonregular_paths() {
let fixture = Fixture::new();
@@ -1929,3 +2025,80 @@ fn branch_changes_rejects_missing_and_multiple_merge_bases_without_snapshot_fall
}
assert_eq!(disk_state(&root), state);
}
#[test]
fn pull_request_refs_fetch_pinned_fork_heads_privately_for_git_and_jj() {
let fixture = Fixture::new();
let source = fixture.dir("source");
init_git(&source);
fs::write(source.join("common"), "base\n").unwrap();
git(&source, &["add", "."]);
git(&source, &["commit", "-m", "base"]);
let base = git(&source, &["rev-parse", "HEAD"]);
fs::write(source.join("target-only"), "not part of the PR\n").unwrap();
git(&source, &["add", "."]);
git(&source, &["commit", "-m", "target advanced"]);
let target = git(&source, &["rev-parse", "HEAD"]);
git(&source, &["checkout", "--detach", &base]);
fs::write(source.join("common"), "fork change\n").unwrap();
git(&source, &["add", "."]);
git(&source, &["commit", "-m", "fork tip"]);
let head = git(&source, &["rev-parse", "HEAD"]);
git(&source, &["update-ref", "refs/pull/7/head", &head]);
for (index, kind) in [RepoKind::Git, RepoKind::Jujutsu, RepoKind::Jujutsu]
.into_iter()
.enumerate()
{
let root = fixture.dir(&format!("active-{index}"));
if kind == RepoKind::Git {
init_git(&root);
fs::write(root.join("unrelated"), "local\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "unrelated local"]);
git(
&root,
&["remote", "add", "origin", source.to_str().unwrap()],
);
} else {
init_jj(&root, index == 2);
jj(
&root,
&["git", "remote", "add", "origin", source.to_str().unwrap()],
);
}
fs::write(root.join("unsaved"), "keep\n").unwrap();
let state = disk_state(&root);
let pinned = |reference: &str, id: &str| Revision {
target: RevisionTarget::RemoteCommit {
remote: "origin".into(),
reference: reference.into(),
id: id.into(),
},
..revision(id, kind)
};
let before = pinned("refs/heads/local", &target);
let after = pinned("refs/pull/7/head", &head);
let mut repository = open(&root);
let comparison = repository
.compare(70, &before, &after, ComparisonMode::BranchChanges, &[])
.unwrap();
assert!(comparison.before.ends_with(&base));
assert_eq!(comparison.files.len(), 1);
assert_eq!(comparison.files[0].path, Path::new("common"));
assert!(comparison.editable_paths.is_empty());
let file = repository.load_file(70, 0).unwrap();
assert_eq!(file.before, ["base"]);
assert_eq!(file.after, ["fork change"]);
assert_eq!(disk_state(&root), state);
// A force-push cannot silently replace the immutable tip selected in the picker.
let absent = pinned("refs/pull/7/head", &"f".repeat(40));
assert!(
repository
.compare(71, &before, &absent, ComparisonMode::BranchChanges, &[])
.unwrap_err()
.contains("reopen :pull_requests")
);
assert_eq!(disk_state(&root), state);
}
}
+275
View File
@@ -0,0 +1,275 @@
#![cfg(unix)]
use std::{
fs,
path::PathBuf,
process::Command,
sync::atomic::{AtomicU64, Ordering},
thread,
time::{Duration, Instant},
};
use vamp::{
editor::{Input, ResolverStatus},
session::EditorSession,
};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Fixture {
root: PathBuf,
base: String,
}
impl Fixture {
fn jj(&self, args: &[&str]) -> String {
let result = Command::new("jj")
.args(["--no-pager", "--color=never"])
.args(args)
.current_dir(&self.root)
.env("JJ_CONFIG", "/dev/null")
.env("JJ_USER", "Resolver Test")
.env("JJ_EMAIL", "resolver@example.invalid")
.env("JJ_TIMESTAMP", "2000-01-01T00:00:00Z")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
String::from_utf8(result.stdout).unwrap().trim().into()
}
fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"vamp-resolve-session-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&root).unwrap();
let mut fixture = Self {
root,
base: String::new(),
};
fixture.jj(&["git", "init", "--no-colocate", "."]);
for name in ["first.txt", "second.txt"] {
fs::write(fixture.root.join(name), Self::text("base")).unwrap();
}
fixture.jj(&["describe", "-m", "base"]);
fixture.base = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
fixture.jj(&["new", &fixture.base]);
for name in ["first.txt", "second.txt"] {
fs::write(fixture.root.join(name), Self::text("left")).unwrap();
}
fixture.jj(&["describe", "-m", "left"]);
let left = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
fixture.jj(&["new", &fixture.base]);
for name in ["first.txt", "second.txt"] {
fs::write(fixture.root.join(name), Self::text("right")).unwrap();
}
fixture.jj(&["describe", "-m", "right"]);
let right = fixture.jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
fixture.jj(&["new", &left, &right, "-m", "conflicted result"]);
fixture
}
fn text(side: &str) -> String {
format!(
"{side} first\n{}{side} last\n",
(0..30)
.map(|n| format!("context_{n}\n"))
.collect::<String>()
)
}
fn open(&self) -> EditorSession {
let config = self.root.join("test-config.rb");
fs::write(
&config,
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\nend\n",
)
.unwrap();
EditorSession::open(
Some(self.root.join("first.txt")),
vec!["text".into()],
Some(&config),
)
.unwrap()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn ready(session: &mut EditorSession) {
let deadline = Instant::now() + Duration::from_secs(15);
loop {
session.poll();
if session.editor().resolver().is_some_and(|resolver| {
!resolver.loading
&& resolver
.files
.iter()
.all(|file| file.status != ResolverStatus::Checking)
}) {
return;
}
assert!(
Instant::now() < deadline,
"resolver did not settle: {}",
session.editor().message()
);
thread::sleep(Duration::from_millis(1));
}
}
#[test]
fn resolver_keeps_unsaved_result_and_undo_then_saves_and_retains_resolved_file() {
let fixture = Fixture::new();
let original_disk = fs::read_to_string(fixture.root.join("first.txt")).unwrap();
let mut session = fixture.open();
for input in [
Input::Char('i'),
Input::Paste("draft\n".into()),
Input::Escape,
] {
session.handle(input);
}
let draft = session.editor().context().text;
let operation = fixture.jj(&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"--limit",
"1",
"-T",
"id",
]);
session.execute("resolve");
ready(&mut session);
let seeded = format!("draft\n{}", Fixture::text("left"));
assert_eq!(session.editor().context().text, seeded);
assert_ne!(seeded, draft);
assert!(session.editor().dirty());
assert_eq!(
fs::read_to_string(fixture.root.join("first.txt")).unwrap(),
original_disk
);
assert_eq!(
fixture.jj(&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"--limit",
"1",
"-T",
"id"
]),
operation
);
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, draft);
session.handle(Input::Char('U'));
assert_eq!(session.editor().context().text, seeded);
let source = session.editor().resolver().unwrap().files[0]
.sources
.iter()
.enumerate()
.filter(|(_, source)| !source.base)
.nth(1)
.unwrap()
.0
+ 1;
session.execute(&format!("resolve.use_source {source}"));
let chosen = session.editor().context().text;
assert_ne!(chosen, seeded);
assert!(chosen.starts_with("draft\n"));
assert!(chosen.contains("context_15\n"));
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, seeded);
session.execute(&format!("resolve.use_source {source}"));
let cursor = session.editor().cursor();
let resolved_text = session.editor().context().text;
session.execute("resolve.view.toggle");
session.execute("resolve.view.toggle");
assert_eq!(session.editor().context().text, resolved_text);
assert_eq!(session.editor().cursor(), cursor);
session.execute("w");
ready(&mut session);
let resolver = session.editor().resolver().unwrap();
assert_eq!(resolver.files.len(), 2);
assert_eq!(
resolver.files[resolver.selected_file].status,
ResolverStatus::Resolved
);
assert_eq!(
fs::read_to_string(fixture.root.join("first.txt")).unwrap(),
resolved_text
);
assert!(
!fixture
.jj(&["--ignore-working-copy", "resolve", "--list"])
.contains("first.txt")
);
assert!(
fixture
.jj(&["--ignore-working-copy", "resolve", "--list"])
.contains("second.txt")
);
}
#[test]
fn resolver_refuses_save_after_checkout_change_even_when_forced_and_preserves_draft() {
let fixture = Fixture::new();
let mut session = fixture.open();
session.execute("resolve");
ready(&mut session);
for input in [
Input::Char('i'),
Input::Paste("keep my draft\n".into()),
Input::Escape,
] {
session.handle(input);
}
let draft = session.editor().context().text;
fixture.jj(&["new", &fixture.base]);
let checkout_disk = fs::read_to_string(fixture.root.join("first.txt")).unwrap();
for save in ["w", "w!"] {
session.execute(save);
ready(&mut session);
assert_eq!(
fs::read_to_string(fixture.root.join("first.txt")).unwrap(),
checkout_disk
);
assert_eq!(session.editor().context().text, draft);
assert!(session.editor().dirty());
}
session.execute("resolve.close");
assert_eq!(session.editor().context().text, draft);
assert!(session.editor().dirty());
}
#[test]
fn resolver_save_preserves_external_writes_and_keeps_the_unsaved_result() {
let fixture = Fixture::new();
let mut session = fixture.open();
session.execute("resolve");
ready(&mut session);
for input in [
Input::Char('i'),
Input::Paste("local draft\n".into()),
Input::Escape,
] {
session.handle(input);
}
let draft = session.editor().context().text;
fs::write(fixture.root.join("first.txt"), "external edit\n").unwrap();
session.execute("w");
ready(&mut session);
assert_eq!(
fs::read_to_string(fixture.root.join("first.txt")).unwrap(),
"external edit\n"
);
assert_eq!(session.editor().context().text, draft);
assert!(session.editor().dirty());
}