Compare commits

..

9 Commits

20 changed files with 6904 additions and 65 deletions
+1
View File
@@ -32,6 +32,7 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal.
| Paste in Normal | `p`/`P` after/before the caret or row for linewise fragments; system clipboard by default | | Paste in Normal | `p`/`P` after/before the caret or row for linewise fragments; system clipboard by default |
| Command palette | Ctrl+p; type to filter, arrows select, Enter accepts, Esc cancels | | Command palette | Ctrl+p; type to filter, arrows select, Enter accepts, Esc cancels |
| Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` | | Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` |
| Repository diff in Normal | `:diff`; select Before then After, Tab changes category, type to search |
Space is the default leader: press its suffix keys in order, not simultaneously. The [editing guide](docs/editing.md) contains the complete key table and behavior reference. Space is the default leader: press its suffix keys in order, not simultaneously. The [editing guide](docs/editing.md) contains the complete key table and behavior reference.
+7 -2
View File
@@ -10,14 +10,17 @@ One crate; no workspace or terminal-coordinate types in the editing backend:
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, leader/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected. - `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, leader/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations. - `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
Virtual file leaves retain their original catalog input index for caller-owned annotations; scanned entries and directories remain unannotated.
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, and aligned rows/shared vertical and independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
- `repository`: discovers jj/Git repositories and local/advertised remote revisions, builds disk snapshots and changed-file hunks, and owns isolated temporary comparison repositories with bounded, cancellable subprocesses. It never saves editor buffers or modifies active refs/checkouts.
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets. - `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
- `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and syntax byte spans; it has no terminal color/geometry or editing dependency. - `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and syntax byte spans; it has no terminal color/geometry or editing dependency.
- `markdown`: owns native Markdown link/reference resolution, local destination decoding, and heading fragment lookup through the installed block/inline grammars. It has no editor lifecycle, terminal, LSP, or external-process dependency. - `markdown`: owns native Markdown link/reference resolution, local destination decoding, and heading fragment lookup through the installed block/inline grammars. It has no editor lifecycle, terminal, LSP, or external-process dependency.
- `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, frontend-independent semantic token kinds/modifiers and completion edit/item DTOs, and versioned asynchronous update contracts. Semantic token and completion updates are native-only, not part of the Ruby wire protocol; it has no LSP protocol types. - `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, frontend-independent semantic token kinds/modifiers and completion edit/item DTOs, and versioned asynchronous update contracts. Semantic token and completion updates are native-only, not part of the Ruby wire protocol; it has no LSP protocol types.
- `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, definition/hover results, completion decoding, and semantic token requests/legend decoding. Its private configuration resolver supplies presets without restricting language IDs. - `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, definition/hover results, completion decoding, and semantic token requests/legend decoding. Its private configuration resolver supplies presets without restricting language IDs.
- `snippet`: owns bounded, frontend-independent template parsing and expansion into plain replacement text and byte-relative tabstops with per-occurrence ancestry. It never edits documents or owns input policy. - `snippet`: owns bounded, frontend-independent template parsing and expansion into plain replacement text and byte-relative tabstops with per-occurrence ancestry. It never edits documents or owns input policy.
- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation/completion, and retires failed plugin ownership. - `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation/completion, and retires failed plugin ownership. A cancellable diff worker owns repository I/O off the input thread; tagged results cannot reopen closed or superseded selections. Diff focus suppresses asynchronous editing/navigation effects without retiring healthy plugins.
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy. - `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID or immutable diff snapshot/file identity, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy.
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module. - `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI. - The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI.
@@ -61,6 +64,8 @@ The buffer viewport includes a fixed left gutter with right-aligned hybrid line
Each buffer has a frontend-owned parser/tree cache. Unchanged revision/path skips comparison and parsing; unchanged text at a later revision also avoids parsing. Actual text changes produce a minimal UTF-8-safe byte edit with newline-aware Tree-sitter Points, edit the prior tree, then parse using that tree for incremental reuse. Grammar changes reset the tree, including TypeScript versus TSX despite their shared display name. Native highlight/local/injection queries resolve capture precedence and scoped definitions into nonoverlapping original-line spans; unchanged injected trees are reused. Buffer switching restores its cache, and closing retires it. Incomplete text and grammar ERROR nodes remain highlightable; genuine language/query/parser failures expose a status error and readable plain text. Full-tree highlight queries still run after incremental parsing—a deliberate ceiling to revisit if measured costly. There is no local semantic inference engine or runtime grammar download. Each buffer has a frontend-owned parser/tree cache. Unchanged revision/path skips comparison and parsing; unchanged text at a later revision also avoids parsing. Actual text changes produce a minimal UTF-8-safe byte edit with newline-aware Tree-sitter Points, edit the prior tree, then parse using that tree for incremental reuse. Grammar changes reset the tree, including TypeScript versus TSX despite their shared display name. Native highlight/local/injection queries resolve capture precedence and scoped definitions into nonoverlapping original-line spans; unchanged injected trees are reused. Buffer switching restores its cache, and closing retires it. Incomplete text and grammar ERROR nodes remain highlightable; genuine language/query/parser failures expose a status error and readable plain text. Full-tree highlight queries still run after incremental parsing—a deliberate ceiling to revisit if measured costly. There is no local semantic inference engine or runtime grammar download.
Diff highlighting lazily owns two instances of the same syntax cache, keyed by the accepted comparison request ID and selected file. Before detects language from the old path when renamed; After uses the new path. Scrolling, focus changes, and explorer toggling reuse parsed spans without copying/reparsing snapshots. A fresh comparison identity prevents stale Working-checkout colors even when labels and paths are unchanged. Closing the view releases snapshot caches. Syntax styles patch foreground/modifiers over diff backgrounds; historical panes never receive live-buffer semantic tokens.
## Clipboard providers ## Clipboard providers
macOS uses fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`, preserving literal RTF/EPS text rather than treating it as rich clipboard data. Linux supports Wayland with `wl-paste`/`wl-copy`, or X11 with `xclip`, on PATH with the corresponding display environment. Other platforms report unsupported access. Commands have a two-second deadline and bounded output; failures terminate/reap their process group. macOS uses fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`, preserving literal RTF/EPS text rather than treating it as rich clipboard data. Linux supports Wayland with `wl-paste`/`wl-copy`, or X11 with `xclip`, on PATH with the corresponding display environment. Other platforms report unsupported access. Commands have a two-second deadline and bounded output; failures terminate/reap their process group.
+12 -1
View File
@@ -16,6 +16,8 @@ Vamp.configure do |config|
config.file_explorer_side = "right" config.file_explorer_side = "right"
config.file_explorer_view = "tree" config.file_explorer_view = "tree"
config.file_picker_view = "tree" config.file_picker_view = "tree"
config.diff_context_lines = 10
config.diff_expand_lines = 15
config.bind "ctrl+h", config.core.left, mode: config.modes.tree config.bind "ctrl+h", config.core.left, mode: config.modes.tree
config.bind "ctrl+l", config.core.right, mode: config.modes.tree config.bind "ctrl+l", config.core.right, mode: config.modes.tree
config.plugin "plugins/greeting.rb" config.plugin "plugins/greeting.rb"
@@ -40,6 +42,8 @@ Plugin paths resolve relative to the config file, not the shell's working direct
`config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors. `config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
`config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration. `config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration.
`config.diff_context_lines = 10` sets how many unchanged aligned lines appear above and below each diff change; it defaults to `10` and accepts nonnegative integers, including `0` for changes only. `config.diff_expand_lines = 15` sets how many additional lines each directional expansion reveals per keypress; it defaults to `15` and accepts positive integers. Both are bounded by the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems). Floats, strings, booleans, negative or oversized values, and a zero expansion step reject the whole Ruby configuration.
## Actions and modes ## Actions and modes
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings, built-in Ex aliases (such as `"q"` or `"w"`), and mode strings are not accepted. Only plugin-owned commands use exact string names (`"greet"`, without `:` or arguments) and can only be bound in Normal mode. Interactive Ex commands such as `:q` and `:w path` remain textual. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility. `config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings, built-in Ex aliases (such as `"q"` or `"w"`), and mode strings are not accepted. Only plugin-owned commands use exact string names (`"greet"`, without `:` or arguments) and can only be bound in Normal mode. Interactive Ex commands such as `:q` and `:w path` remain textual. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
@@ -73,6 +77,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con
| Normal | `save`, `quit`, `force_quit`, `save_and_quit`, `plugins` | | Normal | `save`, `quit`, `force_quit`, `save_and_quit`, `plugins` |
| Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` | | Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` |
| Normal | `follow_link` | | Normal | `follow_link` |
| Normal | `diff` |
| Normal | `jump_back`, `jump_forward` | | Normal | `jump_back`, `jump_forward` |
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` | | Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
| Insert | `lsp.completion`, `snippet.next`, `snippet.previous` | | Insert | `lsp.completion`, `snippet.next`, `snippet.previous` |
@@ -117,7 +122,7 @@ Bindings are resolved inside the editor module, not the terminal adapter. Bracke
## Selection-list bindings ## Selection-list bindings
The palette, floating file picker, and focused docked explorer share `config.modes.selection`. It is a binding context, not a buffer editing mode. `config.core.down` moves to the next list item; `config.core.up` moves to the previous. Defaults are Ctrl+j/Down/Ctrl+n for next and Ctrl+k/Up for previous. Remap or disable any single key: The palette, floating file picker, focused docked explorer, and diff selectors/navigation share `config.modes.selection`. It is a binding context, not a buffer editing mode. `config.core.down` moves to the next list item (or scrolls aligned diff rows); `config.core.up` moves to the previous. Defaults are Ctrl+j/Down/Ctrl+n for next and Ctrl+k/Up for previous. Remap or disable any single key:
```ruby ```ruby
config.bind "ctrl+f", config.core.down, mode: config.modes.selection config.bind "ctrl+f", config.core.down, mode: config.modes.selection
@@ -134,6 +139,10 @@ config.bind "ctrl+f", config.core.right, mode: config.modes.tree
config.bind "ctrl+h", nil, mode: config.modes.tree config.bind "ctrl+h", nil, mode: config.modes.tree
``` ```
The diff's right-docked changed-file explorer uses these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's diff rather than an editable buffer; its catalog comes from the comparison, not a disk scan.
In the diff view, Ctrl+e is reserved for hiding/showing the changed-file explorer and takes precedence over tree/selection remaps. Its query, selection, and expansion state survive hiding; the panes use the reclaimed width.
Ctrl+h is **not** a Backspace alias or default deletion key in any mode. Raw Ctrl+h (0x08) and Backspace (DEL/0x7f) are distinct, as are CSI-u events. In the tree it navigates in NORMAL and INSERT without deleting query text; it never deletes file-buffer Insert text by default. Explicit `config.core.backspace` bindings remain available if desired. Ctrl+h is **not** a Backspace alias or default deletion key in any mode. Raw Ctrl+h (0x08) and Backspace (DEL/0x7f) are distinct, as are CSI-u events. In the tree it navigates in NORMAL and INSERT without deleting query text; it never deletes file-buffer Insert text by default. Explicit `config.core.backspace` bindings remain available if desired.
Ctrl+j is distinct from Enter: in the TUI's raw mode, LF is Ctrl+j and CR is Enter; CSI-u control events also work. No enhanced-terminal negotiation is required. Ctrl+m remains an Enter alias. Outside completion and selection lists, Ctrl+k still clones above in Normal/Visual, and Ctrl+j defaults to newline in Insert or submit in Command/Search. While completion is open, Ctrl+j/k cycles candidates; Enter always closes completion and inserts a newline. Enter and Ctrl+j can be rebound independently outside the completion cycle. Ctrl+j is distinct from Enter: in the TUI's raw mode, LF is Ctrl+j and CR is Enter; CSI-u control events also work. No enhanced-terminal negotiation is required. Ctrl+m remains an Enter alias. Outside completion and selection lists, Ctrl+k still clones above in Normal/Visual, and Ctrl+j defaults to newline in Insert or submit in Command/Search. While completion is open, Ctrl+j/k cycles candidates; Enter always closes completion and inserts a newline. Enter and Ctrl+j can be rebound independently outside the completion cycle.
@@ -210,6 +219,8 @@ Interactive LSP commands use dotted names (`:lsp.hover`, `:lsp.definition`, `:ls
Diagnostics appear as severity-colored gutter numbers and text underlines; the primary caret's diagnostic appears in the message row. `:lsp.diagnostics` reports the current document's server state. Older versioned diagnostics are discarded; unversioned diagnostics can only be associated with the arrival-time document revision. Editing clears previous annotations immediately. Server startup/transport failures are reported and clear that server's diagnostics; other servers and native editing remain available. There are no automatic restarts. Diagnostics appear as severity-colored gutter numbers and text underlines; the primary caret's diagnostic appears in the message row. `:lsp.diagnostics` reports the current document's server state. Older versioned diagnostics are discarded; unversioned diagnostics can only be associated with the arrival-time document revision. Editing clears previous annotations immediately. Server startup/transport failures are reported and clear that server's diagnostics; other servers and native editing remain available. There are no automatic restarts.
Insert mode hides all diagnostic gutter colors, underlines, counts, and caret messages. Incoming results are still stored; leaving Insert reveals only the latest results matching the current text revision. Editing invalidates earlier results, so stale annotations never reappear on exit. Document synchronization stays live for completion and semantic highlighting; this display policy does not stop server-side diagnostic analysis.
`:lsp.hover` or a configured hover action requests information at the primary caret once, regardless of count or selection count. The response opens a read-only floating popup: arrows/Ctrl+h/j/k/l scroll vertically/horizontally, Home/End select the first/last logical line, and Escape dismisses without collapsing carets. Other input dismisses and continues normal editing. Changes to the source buffer/revision/caret, intervening input, or newer requests prevent pending hover replies from reopening it. `:lsp.hover` or a configured hover action requests information at the primary caret once, regardless of count or selection count. The response opens a read-only floating popup: arrows/Ctrl+h/j/k/l scroll vertically/horizontally, Home/End select the first/last logical line, and Escape dismisses without collapsing carets. Other input dismisses and continues normal editing. Changes to the source buffer/revision/caret, intervening input, or newer requests prevent pending hover replies from reopening it.
Hover accepts plaintext/Markdown MarkupContent and string/code MarkedString forms, including arrays. The popup grows up to 100 columns and 24 rows, clamped to the available document workspace and content height. Multiline content and Markdown markers remain visible, with the same Markdown syntax colors as document buffers; named fenced code blocks use their language's bundled highlighter. Highlighting is cached across scrolling and redraws. Horizontal scrolling exposes long lines without splitting graphemes. Missing/unsupported/invalid hover results report a message without changing text, selections, or undo history. Content is bounded to 4 MiB and terminal controls are sanitized. Hover accepts plaintext/Markdown MarkupContent and string/code MarkedString forms, including arrays. The popup grows up to 100 columns and 24 rows, clamped to the available document workspace and content height. Multiline content and Markdown markers remain visible, with the same Markdown syntax colors as document buffers; named fenced code blocks use their language's bundled highlighter. Highlighting is cached across scrolling and redraws. Horizontal scrolling exposes long lines without splitting graphemes. Missing/unsupported/invalid hover results report a message without changing text, selections, or undo history. Content is bounded to 4 MiB and terminal controls are sanitized.
+7
View File
@@ -45,6 +45,12 @@ Rust tests live alongside their modules; native LSP integration checks are in [`
Clipboard checks use isolated providers rather than reading or overwriting the desktop clipboard. Clipboard checks use isolated providers rather than reading or overwriting the desktop clipboard.
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.
Changed-file explorer checks cover reuse of the compact directory tree, removed/historical paths, file-to-directory transitions, remapped tree/selection bindings, filter/open/focus transitions, right-dock geometry, and wheel routing without edits to the underlying document.
Visibility checks preserve browsing and pane state across Ctrl+e, exclude hidden explorers from focus/wheel routing, and verify full-width review. Status checks preserve catalog associations through sorting, compaction, duplicate inputs, and file/directory collisions, and cover colored A/M/D/R markers, selected/unfocused rows, Tree/List filtering, and sanitized rename paths.
Diff styling checks cover independent snapshot language/shebang/injection detection, accepted/stale snapshot identity, real-parser cache freshness/reuse/release, syntax foreground over changed backgrounds, real empty rows versus missing counterparts, scrolling-stable diagonal hatching, and tab/wide/combining/control-character clipping. TestBackend omits wide-glyph continuation cells; terminal smoke verifies their actual styling.
## Performance and process limits ## Performance and process limits
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance. - Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance.
@@ -52,5 +58,6 @@ Clipboard checks use isolated providers rather than reading or overwriting the d
- PTY throughput alone cannot prove terminal presentation stays smooth: capture the editor and terminal while the user reproduces a pause. Synchronized-output commands must not reach the terminal during frame computation, even when individual frames and queued-input catch-up appear fast. - PTY throughput alone cannot prove terminal presentation stays smooth: capture the editor and terminal while the user reproduces a pause. Synchronized-output commands must not reach the terminal during frame computation, even when individual frames and queued-input catch-up appear fast.
- Annotation coordinate validation fast-paths ASCII grapheme lengths using byte lengths with CRLF pair accounting, retaining Unicode segmentation for non-ASCII text. Validation remains atomic and still checks every token; large Unicode buffers or diagnostic batches can require foreground work. - Annotation coordinate validation fast-paths ASCII grapheme lengths using byte lengths with CRLF pair accounting, retaining Unicode segmentation for non-ASCII text. Validation remains atomic and still checks every token; large Unicode buffers or diagnostic batches can require foreground work.
- Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing. - Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing.
- Repository diff currently requires Unix subprocess-group cancellation and Git-backed jj (or Git alone). Each file is limited to 16 MiB; each subprocess output stream and Working/conflict snapshot to 64 MiB; each command to 60 seconds. Limit, transport, and non-commit-tag errors are explicit rather than silently truncated. Temporary comparison stores are removed when the diff closes.
See [syntax caches](architecture.md#syntax-caches) for the current full-tree highlighting ceiling. See [syntax caches](architecture.md#syntax-caches) for the current full-tree highlighting ceiling.
+36
View File
@@ -193,6 +193,42 @@ Clearing returns to the full catalog and reveals the selected file's ancestry in
The project root is the working directory captured at startup. Scanning uses the `ignore` crate for nested `.gitignore` rules and negation; parent/global Git ignore rules are disabled. Hidden regular files and directories are included unless ignored, `.git`/`.jj` metadata is always excluded, and symlink files/directories are not followed. Files open using their original OS paths, not sanitized display labels. Root scan failures report an error; descendant/ignore errors retain reachable entries with an explicit incomplete-scan warning. A failed refresh keeps the prior catalog. There is no background watcher: refresh explicitly after external file changes. The project root is the working directory captured at startup. Scanning uses the `ignore` crate for nested `.gitignore` rules and negation; parent/global Git ignore rules are disabled. Hidden regular files and directories are included unless ignored, `.git`/`.jj` metadata is always excluded, and symlink files/directories are not followed. Files open using their original OS paths, not sanitized display labels. Root scan failures report an error; descendant/ignore errors retain reachable entries with an explicit incomplete-scan warning. A failed refresh keeps the prior catalog. There is no background watcher: refresh explicitly after external file changes.
## Repository diff
Run `:diff` in Normal mode, choose **Before**, then **After**, and press Enter to compare. The command palette also exposes **Diff**; there is no default keybinding. Repository discovery starts at the active file's directory, or the startup project root for an unnamed buffer, and prefers jj over Git.
The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / Branches / Tags** for Git. Type to filter by revision/change ID, commit ID, reference name, or description. **Working checkout** stays first in every category, even when it does not match the query. It compares files on disk, including eligible untracked files; it neither saves nor includes unsaved editor text.
| Selector input | Effect |
| --- | --- |
| Tab / Shift+Tab | Next / previous category |
| Up / Down, Ctrl+k / Ctrl+j | Previous / next entry |
| Backspace / Ctrl+u | Remove a query grapheme / clear query |
| Enter | Choose the entry |
| Esc | After → return to Before; Before → close |
Remote references such as `some-bookmark@origin` appear from remote advertisements, including refs never fetched locally. Selecting one retrieves missing content into a temporary comparison repository. **Load history from origin** (or another configured remote) in Revisions/Commits imports searchable remote history without advancing the selection stage. These operations may require network access and the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout.
The read-only comparison has Before on the left, After on the right, and the changed-file explorer **docked on the right**. The explorer reuses the normal compact directory tree, filtering, and configurable `tree`/`selection` navigation. Its catalog contains only changed paths, including removed and historical files; it does not scan the working directory. Use h/j/k/l or control/arrow navigation, Enter to toggle a folder or open a file, `i` to filter, Ctrl+u to clear, and Ctrl+t to switch Tree/List. Opening a file focuses Before.
**Ctrl+e** hides/shows the explorer in the diff view. Hiding reclaims its entire width for the split panes without losing the filter, selected row, folder expansion, displayed file, or pane scroll offsets. Hiding from Files moves focus to Before; showing restores Files focus. While hidden, Tab/Shift+Tab cycles only between Before and After. Ctrl+e is a reserved diff-view control and takes precedence over tree/selection remaps.
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 the new name followed by `← old/path`; directories have no file-status marker. Ordinary file exploration remains unannotated.
Text panes show change excerpts with **10 unchanged lines above and below** each change, preserving original file line numbers. Overlapping or touching excerpts merge; omitted stretches show an unchanged-line count in both panes. Files with only metadata changes show their notice or “No text changes” rather than unrelated source.
With a text pane focused, **`[` expands above** and **`]` expands below** the excerpt at the top of the viewport, revealing up to **15 lines** per keypress. A hidden-line marker targets the following excerpt, or the preceding excerpt at the end of the file. Expansion clamps at file boundaries and merges excerpts when they meet. Existing visible source stays anchored; expanding above a leading/interior marker brings the newly revealed context into view. Opening a file resets its excerpts to the configured initial context.
Configure these counts in Ruby with `config.diff_context_lines = 10` (nonnegative; `0` shows changes only) and `config.diff_expand_lines = 15` (positive).
Changed text rows use muted red backgrounds in Before and muted green backgrounds in After, including blank tails and real empty lines. 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). Unknown languages retain the normal foreground; historical snapshots do not borrow live-buffer LSP tokens.
Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched.
Tab/Shift+Tab cycles focus between Files, Before, and After. In text panes, Up/Down or Ctrl+j/k scrolls both panes together; Left/Right or Ctrl+h/l scrolls the focused pane horizontally. Home/End goes to the first/last tree row or displayed excerpt/hidden-line row. Wheel input over a text pane scrolls both panes; over Files it navigates visible tree rows without opening them. Esc first leaves filtering when the tree is focused; otherwise it closes the diff and preserves the editor's buffers, cursors, unsaved text, and undo history. Typing and pasted text cannot edit either snapshot.
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.
## Command palette ## Command palette
Ctrl+p opens a searchable overlay without leaving the current mode. It lists each core action once, plus registered plugin commands, with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear. Ctrl+p opens a searchable overlay without leaving the current mode. It lists each core action once, plus registered plugin commands, with their owner, effective bindings in the current mode, and eligible modes. Disabled or overridden bindings are not advertised; actions with no key remain available through the palette. Mode-ineligible entries are dimmed and cannot execute. Retired plugin commands disappear.
+1
View File
@@ -24,6 +24,7 @@ Vamp.configure do |config|
config.bind "g", "greet" config.bind "g", "greet"
config.bind "leader g", "greet" config.bind "leader g", "greet"
config.bind "leader q", config.core.quit config.bind "leader q", config.core.quit
config.bind "leader v", config.core.diff
config.bind "ctrl+r", config.core.redo config.bind "ctrl+r", config.core.redo
config.bind "leader r", config.core.repeat_last_edit config.bind "leader r", config.core.repeat_last_edit
config.bind "ctrl+c", config.core.normal, mode: config.modes.insert config.bind "ctrl+c", config.core.normal, mode: config.modes.insert
+1739
View File
File diff suppressed because it is too large Load Diff
+198 -19
View File
@@ -1,4 +1,5 @@
use crate::clipboard::{ClipboardMode, SystemClipboard}; use crate::clipboard::{ClipboardMode, SystemClipboard};
use crate::diff::{DiffState, Operation, Outcome, Phase};
use crate::files::{DockSide, FilePicker, FilePresentation, FileView}; use crate::files::{DockSide, FilePicker, FilePresentation, FileView};
use crate::plugin::{CompletionItem, Diagnostic, SemanticToken, SnippetTabstop, TextPosition}; use crate::plugin::{CompletionItem, Diagnostic, SemanticToken, SnippetTabstop, TextPosition};
use serde::Serialize; use serde::Serialize;
@@ -553,6 +554,7 @@ core_actions![
"lsp.diagnostics", "lsp.diagnostics",
), ),
("core.save", Action::Save, &[Mode::Normal], "w"), ("core.save", Action::Save, &[Mode::Normal], "w"),
("core.diff", Action::Diff, &[Mode::Normal], "diff"),
("core.quit", Action::Quit, &[Mode::Normal], "q"), ("core.quit", Action::Quit, &[Mode::Normal], "q"),
( (
"core.force_quit", "core.force_quit",
@@ -1061,6 +1063,10 @@ pub struct Editor {
file_picker_view: FileView, file_picker_view: FileView,
file_explorer_view: FileView, file_explorer_view: FileView,
count: Option<usize>, count: Option<usize>,
diff: Option<DiffState>,
diff_context_options: (usize, usize),
next_diff_request: u64,
outstanding_diff_request: Option<u64>,
} }
impl Editor { impl Editor {
@@ -1128,6 +1134,10 @@ impl Editor {
file_picker_view: FileView::Tree, file_picker_view: FileView::Tree,
file_explorer_view: FileView::Tree, file_explorer_view: FileView::Tree,
count: None, count: None,
diff: None,
diff_context_options: (10, 15),
next_diff_request: 0,
outstanding_diff_request: None,
}; };
editor.refresh_selection_navigation_hint(); editor.refresh_selection_navigation_hint();
editor editor
@@ -1228,6 +1238,94 @@ impl Editor {
&self.project_root &self.project_root
} }
pub fn diff(&self) -> Option<&DiffState> {
self.diff.as_ref()
}
pub(crate) fn set_diff_context_options(&mut self, context_lines: usize, expand_lines: usize) {
self.diff_context_options = (context_lines, expand_lines);
if let Some(diff) = &mut self.diff {
diff.set_context_options(context_lines, expand_lines);
}
}
pub(crate) fn take_diff_request(&mut self) -> Option<(u64, Operation)> {
let operation = self.diff.as_mut()?.take_request()?;
self.next_diff_request = self
.next_diff_request
.checked_add(1)
.expect("diff request IDs exhausted");
let id = self.next_diff_request;
self.outstanding_diff_request = Some(id);
Some((id, operation))
}
pub(crate) fn apply_diff_result(&mut self, id: u64, result: Result<Outcome, String>) -> bool {
if self.outstanding_diff_request != Some(id) {
return false;
}
self.outstanding_diff_request = None;
let Some(diff) = &mut self.diff else {
return false;
};
diff.apply_result(id, result);
true
}
fn open_diff(&mut self) {
self.count = None;
self.clear_leader();
self.palette = None;
self.hover = None;
self.startup_diagnostic = None;
self.blur_file_picker();
if self.active.mode == Mode::Command {
self.command_line.clear();
self.active.mode = Mode::Normal;
}
self.outstanding_diff_request = None;
let mut diff = DiffState::new();
diff.set_context_options(self.diff_context_options.0, self.diff_context_options.1);
self.diff = Some(diff);
}
fn handle_diff(&mut self, input: Input) {
// Modal controls take precedence over configured browsing bindings.
let toggle_files = input == Input::Ctrl('e')
&& self
.diff
.as_ref()
.is_some_and(|diff| diff.phase == Phase::View);
if !toggle_files && !matches!(input, Input::Escape | Input::Char('\t') | Input::BackTab) {
if self.handle_tree_binding(&input) {
return;
}
match self.selection_bindings.get(&input) {
Some(Binding::Action(Action::Down)) => {
self.diff.as_mut().expect("active diff").navigate(true);
return;
}
Some(Binding::Action(Action::Up)) => {
self.diff.as_mut().expect("active diff").navigate(false);
return;
}
Some(Binding::Disabled) => return,
_ => {}
}
}
let back = input == Input::Escape
&& self
.diff
.as_ref()
.is_some_and(|diff| diff.phase == Phase::After);
if self.diff.as_mut().expect("active diff").handle(input) {
self.diff = None;
self.outstanding_diff_request = None;
} else if back {
self.outstanding_diff_request = None;
}
}
fn buffer(&self, id: usize) -> Option<&Buffer> { fn buffer(&self, id: usize) -> Option<&Buffer> {
if id == self.active.id { if id == self.active.id {
Some(&self.active) Some(&self.active)
@@ -1335,7 +1433,10 @@ impl Editor {
self.active self.active
.diagnostics .diagnostics
.values() .values()
.filter(|annotations| annotations.revision == self.active.text_revision) .filter(|annotations| {
self.active.mode != Mode::Insert
&& annotations.revision == self.active.text_revision
})
.flat_map(|annotations| &annotations.items) .flat_map(|annotations| &annotations.items)
} }
@@ -2462,7 +2563,11 @@ impl Editor {
} }
pub fn file_navigation_hint(&self) -> &str { pub fn file_navigation_hint(&self) -> &str {
if let Some(picker) = &self.file_picker if let Some(picker) = self
.diff
.as_ref()
.and_then(|diff| diff.files.as_ref())
.or(self.file_picker.as_ref())
&& picker.view == FileView::Tree && picker.view == FileView::Tree
{ {
if picker.searching { if picker.searching {
@@ -2530,10 +2635,12 @@ impl Editor {
} }
fn handle_tree_binding(&mut self, input: &Input) -> bool { fn handle_tree_binding(&mut self, input: &Input) -> bool {
let Some(picker) = self let picker = if let Some(diff) = &mut self.diff {
.file_picker diff.files.as_mut().filter(|_| diff.files_visible)
.as_mut() } else {
.filter(|picker| picker.focused && picker.view == FileView::Tree) self.file_picker.as_mut()
};
let Some(picker) = picker.filter(|picker| picker.focused && picker.view == FileView::Tree)
else { else {
return false; return false;
}; };
@@ -2815,19 +2922,7 @@ impl Editor {
} }
Input::Ctrl('r') => picker.refresh(), Input::Ctrl('r') => picker.refresh(),
Input::Ctrl('t') => picker.toggle_view(), Input::Ctrl('t') => picker.toggle_view(),
Input::Ctrl('u') => picker.clear_filter(), _ if picker.handle_filter_input(&input) => {}
Input::Backspace if picker.view == FileView::List || picker.searching => {
picker.backspace()
}
Input::Char('i') if picker.view == FileView::Tree && !picker.searching => {
picker.start_search()
}
Input::Char(ch)
if !ch.is_control() && (picker.view == FileView::List || picker.searching) =>
{
picker.query.push(ch);
picker.refilter();
}
Input::Enter => { Input::Enter => {
let Some(path) = picker.accept() else { return }; let Some(path) = picker.accept() else { return };
let source = self.jump_location(); let source = self.jump_location();
@@ -2854,6 +2949,10 @@ impl Editor {
Input::Ctrl(ch) => control_key(ch), Input::Ctrl(ch) => control_key(ch),
input => input, input => input,
}; };
if self.diff.is_some() {
self.handle_diff(input);
return None;
}
if self.completion.is_some() { if self.completion.is_some() {
let request = matches!( let request = matches!(
self.bindings self.bindings
@@ -3137,6 +3236,7 @@ impl Editor {
| Action::BufferPrevious | Action::BufferPrevious
| Action::BufferDelete | Action::BufferDelete
| Action::BufferForceDelete | Action::BufferForceDelete
| Action::Diff
| Action::LspDefinition | Action::LspDefinition
| Action::LspHover | Action::LspHover
| Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"), | Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"),
@@ -3650,6 +3750,9 @@ impl Editor {
} }
pub fn execute(&mut self, command: &str) -> Option<Invocation> { pub fn execute(&mut self, command: &str) -> Option<Invocation> {
if self.diff.is_some() {
return None;
}
let command = command.trim().strip_prefix(':').unwrap_or(command.trim()); let command = command.trim().strip_prefix(':').unwrap_or(command.trim());
let (name, args) = command let (name, args) = command
.split_once(char::is_whitespace) .split_once(char::is_whitespace)
@@ -3691,6 +3794,14 @@ impl Editor {
} }
fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option<Invocation> { fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option<Invocation> {
if entry.action == Action::Diff {
if args.is_empty() {
self.open_diff();
} else {
self.set_message("The diff command takes no arguments");
}
return None;
}
self.prepare_command(); self.prepare_command();
let action = entry.action; let action = entry.action;
let name = entry.ex.expect("Ex dispatcher requires an alias"); let name = entry.ex.expect("Ex dispatcher requires an alias");
@@ -3772,6 +3883,13 @@ impl Editor {
/// to the primary caret: Insert runs once there, Replace replaces the buffer. /// to the primary caret: Insert runs once there, Replace replaces the buffer.
/// Rejected responses and message-only effects retain the complete set. /// Rejected responses and message-only effects retain the complete set.
pub fn apply(&mut self, effects: Vec<Effect>) -> Result<(), String> { pub fn apply(&mut self, effects: Vec<Effect>) -> Result<(), String> {
if self.diff.is_some()
&& effects
.iter()
.any(|effect| matches!(effect, Effect::Insert(_) | Effect::Replace(_)))
{
return Err("Diff view is read-only".into());
}
let mut total = 0usize; let mut total = 0usize;
for effect in &effects { for effect in &effects {
let text = match effect { let text = match effect {
@@ -6271,6 +6389,67 @@ mod tests {
} }
} }
#[test]
fn diff_preserves_parked_buffers_visual_carets_and_insert_history() {
let directory = Directory::new();
let a = directory.path("a.txt");
let b = directory.path("b.txt");
fs::write(&a, "alpha").unwrap();
fs::write(&b, "beta").unwrap();
let mut editor = Editor::open(Some(a), vec![]).unwrap();
type_text(&mut editor, "iunsaved");
editor.handle(Input::Escape);
editor.execute(&format!("edit {}", b.display()));
type_text(&mut editor, "iother");
editor.handle(Input::Escape);
type_text(&mut editor, "vl");
let carets = editor.carets();
let selections = editor.selections();
let documents = editor
.documents()
.map(|document| (document.id, document.revision, document.lines.to_vec()))
.collect::<Vec<_>>();
editor.execute("diff");
for input in [
Input::Paste("bad".into()),
Input::Delete,
Input::Char('d'),
Input::Enter,
] {
editor.handle(input);
}
editor.handle(Input::Escape);
assert_eq!(editor.mode(), Mode::Visual);
assert_eq!(editor.carets(), carets);
assert_eq!(editor.selections(), selections);
assert_eq!(
editor
.documents()
.map(|document| (document.id, document.revision, document.lines.to_vec()))
.collect::<Vec<_>>(),
documents
);
editor.handle(Input::Escape);
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "beta");
editor.execute("buffer 1");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "alpha");
editor.handle(Input::Char('i'));
type_text(&mut editor, "start");
let caret = editor.cursor();
editor.execute("diff");
editor.handle(Input::Paste("ignored".into()));
editor.handle(Input::Escape);
assert_eq!(editor.mode(), Mode::Insert);
assert_eq!(editor.cursor(), caret);
type_text(&mut editor, "finish");
editor.handle(Input::Escape);
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "alpha");
}
#[test] #[test]
fn line_actions_counted_delete_restores_caret_and_pastes_whole_rows() { fn line_actions_counted_delete_restores_caret_and_pastes_whole_rows() {
let mut editor = editor(); let mut editor = editor();
+142 -1
View File
@@ -38,20 +38,25 @@ pub struct FileRow<'a> {
/// The actual project-relative OS path, never reconstructed from the label. /// The actual project-relative OS path, never reconstructed from the label.
pub path: &'a Path, pub path: &'a Path,
pub label: &'a str, pub label: &'a str,
/// Original virtual-catalog input index; absent for scanned entries and directories.
pub catalog_index: Option<usize>,
pub is_dir: bool, pub is_dir: bool,
pub depth: usize, pub depth: usize,
pub expanded: bool, pub expanded: bool,
} }
#[derive(Debug)]
struct Node { struct Node {
path: PathBuf, path: PathBuf,
label: String, label: String,
search_label: String, search_label: String,
catalog_index: Option<usize>,
is_dir: bool, is_dir: bool,
parent: Option<usize>, parent: Option<usize>,
children: Vec<usize>, children: Vec<usize>,
} }
#[derive(Debug)]
struct TreeNode { struct TreeNode {
node: usize, node: usize,
label: String, label: String,
@@ -59,6 +64,7 @@ struct TreeNode {
children: Vec<usize>, children: Vec<usize>,
} }
#[derive(Debug)]
struct VisibleRow { struct VisibleRow {
node: usize, node: usize,
tree: Option<usize>, tree: Option<usize>,
@@ -66,6 +72,7 @@ struct VisibleRow {
expanded: bool, expanded: bool,
} }
#[derive(Debug)]
pub struct FilePicker { pub struct FilePicker {
pub root: PathBuf, pub root: PathBuf,
pub query: String, pub query: String,
@@ -149,15 +156,58 @@ impl FilePicker {
path, path,
search_label: label.to_lowercase(), search_label: label.to_lowercase(),
label, label,
catalog_index: None,
is_dir: entry.file_type().unwrap().is_dir(), is_dir: entry.file_type().unwrap().is_dir(),
parent: None, parent: None,
children: Vec::new(), children: Vec::new(),
}); });
} }
Ok(Self::from_nodes(root, nodes, presentation, view, warning))
}
/// Builds the changed-path catalog without consulting the working tree.
pub fn from_paths(root: PathBuf, paths: impl IntoIterator<Item = PathBuf>) -> Self {
let mut catalog = HashMap::new();
for (index, path) in paths.into_iter().enumerate() {
for parent in path
.ancestors()
.skip(1)
.filter(|path| !path.as_os_str().is_empty())
{
catalog.insert((parent.to_owned(), true), None);
}
catalog.entry((path, false)).or_insert(Some(index));
}
let nodes = catalog
.into_iter()
.map(|((path, is_dir), catalog_index)| {
let label = label(&path);
Node {
path,
search_label: label.to_lowercase(),
label,
catalog_index,
is_dir,
parent: None,
children: Vec::new(),
}
})
.collect();
Self::from_nodes(root, nodes, FilePresentation::Docked, FileView::Tree, None)
}
fn from_nodes(
root: PathBuf,
mut nodes: Vec<Node>,
presentation: FilePresentation,
view: FileView,
warning: Option<String>,
) -> Self {
nodes.sort_by(|left, right| left.path.cmp(&right.path)); nodes.sort_by(|left, right| left.path.cmp(&right.path));
let by_path: HashMap<_, _> = nodes let by_path: HashMap<_, _> = nodes
.iter() .iter()
.enumerate() .enumerate()
.filter(|(_, node)| node.is_dir)
.map(|(index, node)| (node.path.clone(), index)) .map(|(index, node)| (node.path.clone(), index))
.collect(); .collect();
let mut roots = Vec::new(); let mut roots = Vec::new();
@@ -223,7 +273,7 @@ impl FilePicker {
} }
picker.project(None, false); picker.project(None, false);
picker.remember_selection(false); picker.remember_selection(false);
Ok(picker) picker
} }
fn compact(&mut self, start: usize, parent: Option<usize>) -> usize { fn compact(&mut self, start: usize, parent: Option<usize>) -> usize {
@@ -265,6 +315,7 @@ impl FilePicker {
.tree .tree
.map_or(node.label.as_str(), |tree| self.tree[tree].label.as_str()), .map_or(node.label.as_str(), |tree| self.tree[tree].label.as_str()),
is_dir: node.is_dir, is_dir: node.is_dir,
catalog_index: node.catalog_index,
depth: row.depth, depth: row.depth,
expanded: row.expanded, expanded: row.expanded,
} }
@@ -321,6 +372,37 @@ impl FilePicker {
} }
} }
pub(crate) fn reveal_path(&mut self, path: &Path) {
if let Some(node) = self
.nodes
.iter()
.position(|node| node.path == path && !node.is_dir)
{
self.reveal(node);
self.project(Some(node), false);
self.remember_selection(true);
}
}
pub(crate) fn handle_filter_input(&mut self, input: &crate::editor::Input) -> bool {
use crate::editor::Input;
match input {
Input::Ctrl('u') => self.clear_filter(),
Input::Backspace if self.view == FileView::List || self.searching => self.backspace(),
Input::Char('i') if self.view == FileView::Tree && !self.searching => {
self.start_search()
}
Input::Char(ch)
if !ch.is_control() && (self.view == FileView::List || self.searching) =>
{
self.query.push(*ch);
self.refilter();
}
_ => return false,
}
true
}
pub fn set_view(&mut self, view: FileView) { pub fn set_view(&mut self, view: FileView) {
if self.view == view { if self.view == view {
return; return;
@@ -699,6 +781,53 @@ impl FilePicker {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn virtual_catalog_compacts_and_retains_file_directory_collisions() {
let mut picker = FilePicker::from_paths(
PathBuf::new(),
["x", "x/y", "src/deep/deleted.rs", "src/deep/deleted.rs"].map(PathBuf::from),
);
assert_eq!(
picker
.entries()
.map(|row| row.label.to_owned())
.collect::<Vec<_>>(),
["src/deep", "x", "x"]
);
picker.reveal_path(Path::new("x"));
assert!(!picker.entries().nth(picker.selected).unwrap().is_dir);
assert_eq!(picker.accept(), Some(PathBuf::from("x")));
picker.reveal_path(Path::new("x/y"));
assert_eq!(picker.accept(), Some(PathBuf::from("x/y")));
picker.reveal_path(Path::new("src/deep/deleted.rs"));
assert_eq!(picker.accept(), Some(PathBuf::from("src/deep/deleted.rs")));
assert_eq!(picker.entries().filter(|row| !row.is_dir).count(), 3);
let expected_index = |row: &FileRow<'_>| {
if row.is_dir {
None
} else {
Some(match row.path.to_str().unwrap() {
"x" => 0,
"x/y" => 1,
"src/deep/deleted.rs" => 2,
path => panic!("unexpected path {path}"),
})
}
};
for view in [FileView::List, FileView::Tree] {
picker.set_view(view);
for query in ["", "x", "deleted"] {
picker.query = query.into();
picker.refilter();
let rows: Vec<_> = picker.entries().collect();
assert!(!rows.is_empty());
for row in rows {
assert_eq!(row.catalog_index, expected_index(&row));
}
}
}
}
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
static TEMP_ID: AtomicU64 = AtomicU64::new(0); static TEMP_ID: AtomicU64 = AtomicU64::new(0);
@@ -732,6 +861,18 @@ mod tests {
.collect() .collect()
} }
#[test]
fn scanned_files_and_directories_have_no_catalog_index() {
let directory = Directory::new();
directory.put("nested/file.txt", "");
let mut picker = directory.scan();
for view in [FileView::List, FileView::Tree] {
picker.set_view(view);
picker.reveal_path(Path::new("nested/file.txt"));
assert!(picker.entries().all(|row| row.catalog_index.is_none()));
}
}
#[test] #[test]
fn nested_ignore_negation_hidden_files_and_vcs_metadata() { fn nested_ignore_negation_hidden_files_and_vcs_metadata() {
let directory = Directory::new(); let directory = Directory::new();
+2
View File
@@ -1,10 +1,12 @@
pub mod clipboard; pub mod clipboard;
pub mod diff;
pub mod editor; pub mod editor;
pub mod files; pub mod files;
pub mod lsp; pub mod lsp;
mod lsp_config; mod lsp_config;
mod markdown; mod markdown;
pub mod plugin; pub mod plugin;
pub mod repository;
pub mod ruby; pub mod ruby;
pub mod session; pub mod session;
mod snippet; mod snippet;
+4
View File
@@ -40,6 +40,10 @@ Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]
Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N] Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N]
:q refuses any dirty buffer; :wq saves active and quits only if all are clean :q refuses any dirty buffer; :wq saves active and quits only if all are clean
Markdown links: :follow_link (primary caret; local files only) Markdown links: :follow_link (primary caret; local files only)
Diff: :diff (Normal); choose Before then After; Working checkout means disk, not unsaved buffers
Tab/Shift+Tab categories/focus; type to search IDs/refs; arrows navigate/scroll; Esc back/close
Remote refs/history load into temporary comparison repos without changing active refs/checkout
Changed-file tree: Ctrl+e hides/shows; A added, M modified, D removed, R renamed (old path shown)
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
Set VAMP_RUBY to choose the Ruby executable. Set VAMP_RUBY to choose the Ruby executable.
+1393
View File
File diff suppressed because it is too large Load Diff
+101 -4
View File
@@ -34,6 +34,8 @@ pub struct Config {
pub file_picker_view: String, pub file_picker_view: String,
pub file_explorer_view: String, pub file_explorer_view: String,
pub clipboard_mode: String, pub clipboard_mode: String,
pub diff_context_lines: usize,
pub diff_expand_lines: usize,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -68,6 +70,8 @@ impl Default for Config {
file_picker_view: "tree".into(), file_picker_view: "tree".into(),
file_explorer_view: "tree".into(), file_explorer_view: "tree".into(),
clipboard_mode: "system".into(), clipboard_mode: "system".into(),
diff_context_lines: 10,
diff_expand_lines: 15,
} }
} }
} }
@@ -88,6 +92,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
file_picker_view, file_picker_view,
file_explorer_view, file_explorer_view,
clipboard_mode, clipboard_mode,
diff_context_lines,
diff_expand_lines,
.. ..
} => { } => {
let mut config = Config::default(); let mut config = Config::default();
@@ -110,6 +116,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.file_picker_view = file_picker_view; config.file_picker_view = file_picker_view;
config.file_explorer_view = file_explorer_view; config.file_explorer_view = file_explorer_view;
config.clipboard_mode = clipboard_mode; config.clipboard_mode = clipboard_mode;
config.diff_context_lines = diff_context_lines;
config.diff_expand_lines = diff_expand_lines;
Ok(config) Ok(config)
} }
Response::Error { Response::Error {
@@ -372,6 +380,34 @@ struct DocumentRequest<'a> {
event: &'a DocumentEvent, event: &'a DocumentEvent,
} }
fn diff_line_count<'de, D: serde::Deserializer<'de>>(
deserializer: D,
label: &str,
minimum: usize,
) -> Result<usize, D::Error> {
let value = serde_json::Value::deserialize(deserializer)?;
value
.as_u64()
.and_then(|value| usize::try_from(value).ok())
.filter(|value| *value >= minimum)
.ok_or_else(|| {
serde::de::Error::custom(format!(
"{label} must be an Integer between {minimum} and {}",
usize::MAX
))
})
}
fn diff_context_lines<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<usize, D::Error> {
diff_line_count(deserializer, "diff_context_lines", 0)
}
fn diff_expand_lines<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<usize, D::Error> {
diff_line_count(deserializer, "diff_expand_lines", 1)
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(tag = "type", deny_unknown_fields)] #[serde(tag = "type", deny_unknown_fields)]
enum Response { enum Response {
@@ -397,6 +433,10 @@ enum Response {
file_picker_view: String, file_picker_view: String,
file_explorer_view: String, file_explorer_view: String,
clipboard_mode: String, clipboard_mode: String,
#[serde(deserialize_with = "diff_context_lines")]
diff_context_lines: usize,
#[serde(deserialize_with = "diff_expand_lines")]
diff_expand_lines: usize,
}, },
#[serde(rename = "result")] #[serde(rename = "result")]
Result { Result {
@@ -1048,14 +1088,71 @@ end
assert!(load_config(&path).unwrap_err().contains(error), "{value}"); assert!(load_config(&path).unwrap_err().contains(error), "{value}");
} }
for frame in [ for frame in [
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#, r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","diff_context_lines":10,"diff_expand_lines":15}"#,
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"}"#, 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","diff_context_lines":10,"diff_expand_lines":15}"#,
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"}"#, 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","diff_context_lines":10,"diff_expand_lines":15}"#,
] { ] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}"); assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
} }
} }
#[test]
fn config_diff_excerpt_counts_accept_custom_values_and_reject_invalid_values() {
let fixture = Fixture::new();
for (context, expand) in [(0, 7), (3, 1), (usize::MAX, usize::MAX)] {
let path = fixture.file(
"config.rb",
&format!(
"Vamp.configure {{ |config| config.diff_context_lines = {context}; config.diff_expand_lines = {expand} }}"
),
);
let loaded = load_config(&path).unwrap();
assert_eq!(
(loaded.diff_context_lines, loaded.diff_expand_lines),
(context, expand)
);
}
let frame = serde_json::json!({
"version": 1, "type": "config", "plugins": [], "lsp": {},
"bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree",
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7
});
for setting in ["diff_context_lines", "diff_expand_lines"] {
for value in [
"-1",
"1.5",
"\"3\"",
"true",
"false",
"nil",
"18446744073709551616",
"0",
] {
if setting == "diff_context_lines" && value == "0" {
continue;
}
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.{setting} = {value} }}"),
);
let error = load_config(&path).unwrap_err();
assert!(
error.contains(setting) && error.contains("must be"),
"{value}: {error}"
);
let mut invalid_frame = frame.clone();
invalid_frame[setting] =
serde_json::from_str(if value == "nil" { "null" } else { value }).unwrap();
let error = serde_json::from_value::<Response>(invalid_frame)
.err()
.unwrap();
assert!(error.to_string().contains(setting), "{value}: {error}");
}
}
}
#[test] #[test]
fn lsp_configuration_is_disabled_unless_explicitly_enabled() { fn lsp_configuration_is_disabled_unless_explicitly_enabled() {
let fixture = Fixture::new(); let fixture = Fixture::new();
@@ -1431,7 +1528,7 @@ end"#,
"version": 1, "type": "config", "plugins": [], "lsp": {}, "version": 1, "type": "config", "plugins": [], "lsp": {},
"bindings": [], "leader": "space", "file_explorer_side": "right", "bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree", "file_picker_view": "tree", "file_explorer_view": "tree",
"clipboard_mode": "system" "clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15
}); });
let Response::Config { lsp, .. } = let Response::Config { lsp, .. } =
serde_json::from_value::<Response>(frame.clone()).unwrap() serde_json::from_value::<Response>(frame.clone()).unwrap()
+30 -1
View File
@@ -16,6 +16,8 @@ module Vamp
@file_picker_view = "tree" @file_picker_view = "tree"
@file_explorer_view = "tree" @file_explorer_view = "tree"
@clipboard_mode = "system" @clipboard_mode = "system"
@diff_context_lines = 10
@diff_expand_lines = 15
end end
# @param path [String] relative to the configuration file # @param path [String] relative to the configuration file
@@ -60,6 +62,18 @@ module Vamp
@clipboard_mode = nonempty_string!(mode, "clipboard mode") @clipboard_mode = nonempty_string!(mode, "clipboard mode")
end end
# @param lines [Integer] nonnegative context lines around each diff change
# @return [Integer]
def diff_context_lines=(lines)
@diff_context_lines = diff_line_count!(lines, "diff_context_lines", 0)
end
# @param lines [Integer] positive lines revealed per diff expansion
# @return [Integer]
def diff_expand_lines=(lines)
@diff_expand_lines = diff_line_count!(lines, "diff_expand_lines", 1)
end
# @return [Module<Vamp::Core>] frozen namespace with catalog-generated Action methods # @return [Module<Vamp::Core>] frozen namespace with catalog-generated Action methods
def core def core
Core Core
@@ -90,11 +104,26 @@ module Vamp
def response def response
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings, { "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings,
"leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view, "leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view,
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode } "file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines }
end end
private private
# @param value [Integer]
# @param label [String]
# @param minimum [Integer]
# @return [Integer]
def diff_line_count!(value, label, minimum)
raise TypeError, "#{label} must be an Integer" unless value.is_a?(Integer)
maximum = (1 << 64) - 1
unless value.between?(minimum, maximum)
raise ArgumentError, "#{label} must be between #{minimum} and #{maximum}"
end
value
end
# @param value [String] # @param value [String]
# @param label [String] # @param label [String]
# @return [String] # @return [String]
+247 -2
View File
@@ -2,18 +2,125 @@ use std::{
collections::BTreeMap, collections::BTreeMap,
io, io,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
mpsc::{self, Receiver, SyncSender},
},
thread::{self, JoinHandle},
}; };
use crate::{ use crate::{
diff::{Operation as DiffOperation, Outcome as DiffOutcome},
editor::{Cursor, Editor, Effect, Input, Invocation, Mode}, editor::{Cursor, Editor, Effect, Input, Invocation, Mode},
lsp::Lsp, lsp::Lsp,
plugin::{ plugin::{
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate, DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
TextPosition, UpdateEffect, TextPosition, UpdateEffect,
}, },
repository::Repository,
ruby::{Config, RubyPlugin, load_config}, ruby::{Config, RubyPlugin, load_config},
}; };
type DiffResult = (u64, Result<DiffOutcome, String>);
struct DiffWorker {
requests: Option<SyncSender<(u64, DiffOperation)>>,
results: Option<Receiver<DiffResult>>,
cancelled: Arc<AtomicBool>,
stopped: Arc<AtomicBool>,
cancelled_request: Arc<AtomicU64>,
thread: Option<JoinHandle<()>>,
pending: Option<u64>,
}
impl DiffWorker {
fn start(root: PathBuf) -> Result<Self, String> {
let (requests, incoming) = mpsc::sync_channel::<(u64, DiffOperation)>(1);
let (outgoing, results) = mpsc::sync_channel(1);
let cancelled = Arc::new(AtomicBool::new(false));
let stopped = Arc::new(AtomicBool::new(false));
let cancelled_request = Arc::new(AtomicU64::new(0));
let cancelled_id = Arc::clone(&cancelled_request);
let stop = Arc::clone(&stopped);
let cancellation = Arc::clone(&cancelled);
let thread = thread::Builder::new()
.name("vamp-diff".into())
.spawn(move || {
let mut repository = None;
while let Ok((id, operation)) = incoming.recv() {
if stop.load(Ordering::Acquire) {
break;
}
cancellation.store(false, Ordering::Release);
if stop.load(Ordering::Acquire) {
break;
}
if cancelled_id.load(Ordering::Acquire) == id {
continue;
}
let result = (|| {
if repository.is_none() {
repository = Some(Repository::open(&root, Arc::clone(&cancellation))?);
}
let repository = repository.as_mut().expect("repository opened");
match operation {
DiffOperation::Discover => {
repository.catalog().map(DiffOutcome::Catalog)
}
DiffOperation::FetchRemote(remote) => {
repository.fetch_remote(&remote).map(DiffOutcome::Catalog)
}
DiffOperation::Compare { before, after } => repository
.compare(&before, &after)
.map(DiffOutcome::Comparison),
}
})();
if stop.load(Ordering::Acquire) || outgoing.send((id, result)).is_err() {
break;
}
}
})
.map_err(|error| format!("Cannot start diff worker: {error}"))?;
Ok(Self {
requests: Some(requests),
results: Some(results),
cancelled,
stopped,
cancelled_request,
thread: Some(thread),
pending: None,
})
}
fn request(&mut self, id: u64, operation: DiffOperation) -> Result<(), String> {
self.requests
.as_ref()
.expect("live diff worker has a sender")
.try_send((id, operation))
.map_err(|error| match error {
mpsc::TrySendError::Full(_) => "Diff worker is already busy".to_owned(),
mpsc::TrySendError::Disconnected(_) => {
"Diff worker stopped unexpectedly".to_owned()
}
})?;
self.pending = Some(id);
Ok(())
}
}
impl Drop for DiffWorker {
fn drop(&mut self) {
self.stopped.store(true, Ordering::Release);
self.cancelled.store(true, Ordering::Release);
self.requests.take();
self.results.take();
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
struct TrackedDocument { struct TrackedDocument {
snapshot: DocumentSnapshot, snapshot: DocumentSnapshot,
save_revision: u64, save_revision: u64,
@@ -45,6 +152,7 @@ pub struct EditorSession {
pending_hover: bool, pending_hover: bool,
pending_completion: bool, pending_completion: bool,
hover_owner: Option<String>, hover_owner: Option<String>,
diff_worker: Option<DiffWorker>,
} }
impl EditorSession { impl EditorSession {
@@ -94,6 +202,7 @@ impl EditorSession {
config.clipboard_mode config.clipboard_mode
)); ));
} }
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
let mut plugins = BTreeMap::new(); let mut plugins = BTreeMap::new();
for configured in config.plugins { for configured in config.plugins {
let path = configured.path; let path = configured.path;
@@ -135,6 +244,7 @@ impl EditorSession {
pending_hover: false, pending_hover: false,
pending_completion: false, pending_completion: false,
hover_owner: None, hover_owner: None,
diff_worker: None,
}; };
session.synchronize_documents(); session.synchronize_documents();
Ok(session) Ok(session)
@@ -152,6 +262,7 @@ impl EditorSession {
self.dispatch(invocation); self.dispatch(invocation);
self.synchronize_documents(); self.synchronize_documents();
} }
self.start_diff_request();
} }
pub fn execute(&mut self, command: &str) { pub fn execute(&mut self, command: &str) {
@@ -162,6 +273,82 @@ impl EditorSession {
self.dispatch(invocation); self.dispatch(invocation);
self.synchronize_documents(); self.synchronize_documents();
} }
self.start_diff_request();
}
fn start_diff_request(&mut self) {
if self.editor.diff().is_none() {
self.diff_worker = None;
return;
}
if self.editor.diff().is_some_and(|diff| !diff.loading)
&& let Some(worker) = &mut self.diff_worker
&& let Some(id) = worker.pending.take()
{
worker.cancelled_request.store(id, Ordering::Release);
worker.cancelled.store(true, Ordering::Release);
}
let Some((id, operation)) = self.editor.take_diff_request() else {
return;
};
if self.diff_worker.is_none() {
let root = self
.editor
.path()
.and_then(Path::parent)
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| self.editor.project_root())
.to_path_buf();
match DiffWorker::start(root) {
Ok(worker) => self.diff_worker = Some(worker),
Err(error) => {
self.editor.apply_diff_result(id, Err(error));
return;
}
}
}
if let Err(error) = self
.diff_worker
.as_mut()
.expect("diff worker started")
.request(id, operation)
{
self.editor.apply_diff_result(id, Err(error));
self.diff_worker = None;
}
}
fn poll_diff(&mut self) -> bool {
self.start_diff_request();
let mut changed = false;
while let Some(worker) = self.diff_worker.as_ref() {
let result = worker
.results
.as_ref()
.expect("live diff worker has results")
.try_recv();
match result {
Ok((id, result)) => {
if let Some(worker) = &mut self.diff_worker
&& worker.pending == Some(id)
{
worker.pending = None;
}
changed |= self.editor.apply_diff_result(id, result);
}
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
if let Some(id) = self.diff_worker.as_ref().and_then(|worker| worker.pending) {
changed |= self
.editor
.apply_diff_result(id, Err("Diff worker stopped unexpectedly".into()));
}
self.diff_worker = None;
break;
}
}
}
changed
} }
fn dispatch(&mut self, invocation: Invocation) { fn dispatch(&mut self, invocation: Invocation) {
@@ -368,6 +555,9 @@ impl EditorSession {
request_id: Option<u64>, request_id: Option<u64>,
source: InvocationSource, source: InvocationSource,
) -> bool { ) -> bool {
if self.editor.diff().is_some() {
return false;
}
self.editor.buffer_id() == source.buffer_id self.editor.buffer_id() == source.buffer_id
&& self.source_is_current(source) && self.source_is_current(source)
&& (owner != "core" && (owner != "core"
@@ -617,7 +807,10 @@ impl EditorSession {
let Some(source) = source else { let Some(source) = source else {
return Err("plugin response has an unknown invocation ID".into()); return Err("plugin response has an unknown invocation ID".into());
}; };
if !self.source_is_current(source) || self.editor.buffer_id() != source.buffer_id { if !self.source_is_current(source)
|| self.editor.buffer_id() != source.buffer_id
|| self.editor.diff().is_some()
{
return Ok(false); return Ok(false);
} }
if effects if effects
@@ -641,6 +834,7 @@ impl EditorSession {
/// Drains ready plugin work without waiting for command or server responses. /// Drains ready plugin work without waiting for command or server responses.
pub fn poll(&mut self) -> bool { pub fn poll(&mut self) -> bool {
let mut changed = self.synchronize_documents(); let mut changed = self.synchronize_documents();
changed |= self.poll_diff();
for update in self.lsp.poll() { for update in self.lsp.poll() {
match self.apply_update("core", update) { match self.apply_update("core", update) {
Ok(applied) => changed |= applied, Ok(applied) => changed |= applied,
@@ -2805,7 +2999,7 @@ end
.map(|diagnostic| diagnostic.message.as_str()), .map(|diagnostic| diagnostic.message.as_str()),
None None
); );
assert_eq!(session.editor().diagnostics().count(), 1); assert_eq!(session.editor().diagnostics().count(), 0);
session.handle(Input::Char('y')); session.handle(Input::Char('y'));
assert_eq!(session.editor().diagnostics().count(), 0); assert_eq!(session.editor().diagnostics().count(), 0);
session.handle(Input::Escape); session.handle(Input::Escape);
@@ -3076,4 +3270,55 @@ end
assert_eq!(invokes[0]["column"], 2); assert_eq!(invokes[0]["column"], 2);
assert_eq!(invokes[0]["revision"], 0); assert_eq!(invokes[0]["revision"], 0);
} }
#[test]
fn pending_plugin_edits_do_not_modify_diff_buffers_or_disable_the_plugin() {
let fixture = Fixture::new();
let source = fixture.file("buffer.txt", "original");
let started = fixture.0.join("started");
let release = fixture.0.join("release");
fixture.file(
"delayed.rb",
r#"Vamp.plugin "delayed", requires: ["text"], async: true
Vamp.command "slow" do |editor, _|
File.write(Vamp.options.fetch("started"), "ready")
sleep 0.001 until File.exist?(Vamp.options.fetch("release"))
editor.insert "late edit"
end
Vamp.command "ping" do |editor, _|
editor.message "still alive"
end
"#,
);
let config = fixture.file(
"config.rb",
&format!(
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.plugin 'delayed.rb', options: {{started: {}, release: {}}}\nend\n",
serde_json::to_string(&started.to_string_lossy()).unwrap(),
serde_json::to_string(&release.to_string_lossy()).unwrap(),
),
);
let mut session =
EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap();
session.execute("slow");
drain_until(&mut session, |_| started.exists());
session.execute("diff");
assert!(session.editor().diff().is_some());
fs::write(&release, "release").unwrap();
// Wait for transport consumption, then assert the consumer-visible result.
drain_until(&mut session, |session| {
session
.requests
.get("delayed")
.is_none_or(|requests| requests.pending.is_empty())
});
assert_eq!(session.editor().context().text, "original");
assert_eq!(fs::read_to_string(&source).unwrap(), "original");
session.handle(Input::Escape);
session.execute("ping");
drain_until(&mut session, |session| {
session.editor().message() == "still alive"
});
assert_eq!(session.editor().context().text, "original");
}
} }
+5
View File
@@ -156,6 +156,11 @@ impl SyntaxCache {
self.error.as_deref() self.error.as_deref()
} }
#[cfg(test)]
pub(crate) fn parse_counts(&self) -> (usize, usize) {
(self.parses, self.injection_parses)
}
fn highlight( fn highlight(
&mut self, &mut self,
syntax: &'static LanguageSpec, syntax: &'static LanguageSpec,
+1790 -34
View File
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
use std::{
fs,
path::{Path, PathBuf},
process::Command,
sync::atomic::{AtomicU64, Ordering},
thread,
time::{Duration, Instant},
};
use vamp::{diff::Phase, editor::Input, session::EditorSession};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"vamp-diff-session-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed),
));
fs::create_dir_all(root.join("repo")).unwrap();
Self(root)
}
fn git(&self, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(self.0.join("repo"))
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_AUTHOR_NAME", "Diff Fixture")
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
.env("GIT_COMMITTER_NAME", "Diff Fixture")
.env("GIT_COMMITTER_EMAIL", "fixture@example.invalid")
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn wait_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) {
let deadline = Instant::now() + Duration::from_secs(10);
while !ready(session) {
session.poll();
assert!(
Instant::now() < deadline,
"diff state: {:?}",
session.editor().diff()
);
if let Some(diff) = session.editor().diff() {
assert!(diff.error.is_none(), "{:?}", diff.error);
}
thread::sleep(Duration::from_millis(1));
}
}
fn type_query(session: &mut EditorSession, text: &str) {
for ch in text.chars() {
session.handle(Input::Char(ch));
}
}
#[test]
fn typed_diff_compares_disk_not_unsaved_buffers_and_preserves_undo() {
let fixture = Fixture::new();
fixture.git(&["init", "-q"]);
let source = fixture.0.join("repo/buffer.rs");
fs::write(&source, "before\n").unwrap();
fixture.git(&["add", "buffer.rs"]);
fixture.git(&["commit", "-qm", "base"]);
let base = fixture.git(&["rev-parse", "HEAD"]);
fs::write(&source, "after\n").unwrap();
let config = fixture.0.join("config.rb");
fs::write(
&config,
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.bind 'leader d', config.core.diff\nend\n",
)
.unwrap();
let mut session =
EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap();
assert!(session.editor().startup_diagnostic().is_none());
for input in [Input::Char('i'), Input::Char('u'), Input::Escape] {
session.handle(input);
}
let cursor = session.editor().cursor();
session.handle(Input::Char(' '));
session.handle(Input::Char('d'));
wait_until(&mut session, |session| {
session
.editor()
.diff()
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
});
type_query(&mut session, &base);
session.handle(Input::Down);
session.handle(Input::Enter);
assert_eq!(session.editor().diff().unwrap().phase, Phase::After);
session.handle(Input::Enter);
wait_until(&mut session, |session| {
session
.editor()
.diff()
.is_some_and(|diff| diff.phase == Phase::View)
});
let comparison = session
.editor()
.diff()
.unwrap()
.comparison
.as_ref()
.unwrap();
assert_eq!(comparison.files.len(), 1);
let file = &comparison.files[0];
assert_eq!(file.path, Path::new("buffer.rs"));
assert_eq!(file.before, ["before"]);
assert_eq!(file.after, ["after"]);
for input in [
Input::Char('i'),
Input::Char('x'),
Input::Paste("overwrite".into()),
] {
session.handle(input);
}
session.handle(Input::Escape);
assert!(
!session
.editor()
.diff()
.unwrap()
.files
.as_ref()
.unwrap()
.searching
);
session.handle(Input::Escape);
assert!(session.editor().diff().is_none());
assert_eq!(session.editor().context().text, "uafter\n");
assert!(session.editor().dirty());
assert_eq!(session.editor().cursor(), cursor);
assert_eq!(fs::read_to_string(&source).unwrap(), "after\n");
session.handle(Input::Char('u'));
assert_eq!(session.editor().context().text, "after\n");
session.execute(":diff");
assert!(session.editor().diff().is_some());
session.handle(Input::Escape);
session.poll();
assert!(session.editor().diff().is_none());
assert_eq!(fixture.git(&["rev-parse", "HEAD"]), base);
assert_eq!(fixture.git(&["status", "--porcelain"]), "M buffer.rs");
}
+2 -1
View File
@@ -2576,8 +2576,9 @@ fn lsp_completion_feature_errors_keep_healthy_services() {
completion_reply(&mut session, &fixture, index as u64 + 1, response); completion_reply(&mut session, &fixture, index as u64 + 1, response);
assert!(session.editor().completion().is_none()); assert!(session.editor().completion().is_none());
assert_eq!(session.editor().context().text, "A😀e\u{301}Z\n"); assert_eq!(session.editor().context().text, "A😀e\u{301}Z\n");
assert_eq!(session.editor().diagnostics().count(), 2); assert_eq!(session.editor().diagnostics().count(), 0);
session.handle(Input::Escape); session.handle(Input::Escape);
assert_eq!(session.editor().diagnostics().count(), 2);
} }
session.execute("lsp.hover"); session.execute("lsp.hover");
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
+1022
View File
File diff suppressed because it is too large Load Diff