Compare commits
6 Commits
704dce2539
...
f43a43fd53
| Author | SHA1 | Date | |
|---|---|---|---|
| f43a43fd53 | |||
| fc960ea0bf | |||
| 976cfc9e62 | |||
| 86d22d4fa2 | |||
| 54bb196192 | |||
| 9a34669c08 |
+6
-1
@@ -23,10 +23,15 @@ The presentation and input experience through which a user interacts with an edi
|
|||||||
**Capability**:
|
**Capability**:
|
||||||
A named frontend ability required by a feature for compatibility, not a permission granted to that feature.
|
A named frontend ability required by a feature for compatibility, not a permission granted to that feature.
|
||||||
|
|
||||||
|
**Canonical Ex command**:
|
||||||
|
The full command name of a command-capable core action, without its `core.` prefix. For example, `core.quit` names the canonical `quit` command.
|
||||||
|
|
||||||
|
**Command alias**:
|
||||||
|
A shorthand spelling of a canonical Ex command, such as `q` for `quit`. An alias refers to the same action, not a separate command implementation.
|
||||||
|
|
||||||
## Configuration interfaces
|
## Configuration interfaces
|
||||||
|
|
||||||
- Prefer typed objects for editor-owned actions, modes, and other closed sets. Expose new core binding targets through `config.core` rather than requiring command-name strings.
|
- Prefer typed objects for editor-owned actions, modes, and other closed sets. Expose new core binding targets through `config.core` rather than requiring command-name strings.
|
||||||
- `src/editor.rs`'s `CORE_ACTIONS` is authoritative for core action names, eligibility, and Ex aliases. Ruby action methods and nested namespaces, protected core command registration, and palette entries are generated from this catalog; add or rename actions there rather than maintaining separate lists.
|
|
||||||
- Use strings for open-ended identifiers: language IDs, paths, and external plugin command names. Interactive Ex command names remain textual.
|
- Use strings for open-ended identifiers: language IDs, paths, and external plugin command names. Interactive Ex command names remain textual.
|
||||||
- Ruby action/mode objects provide validation during configuration evaluation and discoverable members, not compile-time enum safety.
|
- Ruby action/mode objects provide validation during configuration evaluation and discoverable members, not compile-time enum safety.
|
||||||
|
|
||||||
|
|||||||
Generated
+7
@@ -1342,6 +1342,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "similar"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -1839,6 +1845,7 @@ dependencies = [
|
|||||||
"ratatui",
|
"ratatui",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"similar",
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
"tree-sitter-bash",
|
"tree-sitter-bash",
|
||||||
"tree-sitter-c",
|
"tree-sitter-c",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ ignore = "0.4"
|
|||||||
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "underline-color", "unstable-backend-writer"] }
|
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "underline-color", "unstable-backend-writer"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
similar = { version = "2", default-features = false }
|
||||||
tree-sitter = "0.27.1"
|
tree-sitter = "0.27.1"
|
||||||
tree-sitter-bash = "0.25.1"
|
tree-sitter-bash = "0.25.1"
|
||||||
tree-sitter-c = "0.24.2"
|
tree-sitter-c = "0.24.2"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty
|
|||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal. Type `:w` and Enter to save, or `:w path` to name an unnamed buffer. Type `:q` and Enter to quit; it refuses unsaved changes in any open buffer. `:q!` explicitly discards changes and quits.
|
Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal. Type `:save` (alias `:w`) and Enter to save, or `:save path` to name an unnamed buffer. Type `:quit` (alias `:q`) and Enter to quit; it refuses unsaved changes in any open buffer. `:force_quit` (alias `:q!`) explicitly discards changes and quits.
|
||||||
|
|
||||||
| Task | Default keys |
|
| Task | Default keys |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -32,7 +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 |
|
| Repository diff in Normal | `:diff`; choose Before then After, Tab changes category, type to search; Working checkout After is editable; `:diff.close` exits |
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -46,10 +46,12 @@ Plugin paths resolve relative to the config file, not the shell's working direct
|
|||||||
|
|
||||||
## 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, canonical Ex names (such as `"quit"`), shorthand aliases (such as `"q"`), 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 remain textual. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
|
||||||
|
|
||||||
Registered command names may contain dot-separated segments, each starting with an ASCII letter followed by ASCII letters/digits/underscores/hyphens, plus one optional trailing `!`. Names cannot contain whitespace, empty segments, or leading/trailing dots. This grammar applies to core and plugin commands alike.
|
Registered command names may contain dot-separated segments, each starting with an ASCII letter followed by ASCII letters/digits/underscores/hyphens, plus one optional trailing `!`. Names cannot contain whitespace, empty segments, or leading/trailing dots. This grammar applies to core and plugin commands alike.
|
||||||
|
|
||||||
|
For command-capable actions, canonical Ex names derive automatically from the action name without `core.`: `config.core.quit` corresponds to `:quit`, while `config.core.diff.close` corresponds to `:diff.close`. The single Rust alias table targets typed actions, so shorthand names such as `:q` follow the canonical name without repeating its spelling. Both canonical names and aliases are reserved against plugin registration; the Ruby ownership map comes from the same catalog and alias table. Native-only action eligibility is unchanged.
|
||||||
|
|
||||||
## Key notation
|
## Key notation
|
||||||
|
|
||||||
Key notation follows [Herdr's readable style](https://herdr.dev/docs/configuration/#keybindings). Literal non-control Unicode characters are case-sensitive (`n`, `J`, `+`); named keys and modifiers are case-insensitive:
|
Key notation follows [Herdr's readable style](https://herdr.dev/docs/configuration/#keybindings). Literal non-control Unicode characters are case-sensitive (`n`, `J`, `+`); named keys and modifiers are case-insensitive:
|
||||||
@@ -78,6 +80,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con
|
|||||||
| 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 | `diff` |
|
||||||
|
| Normal, Insert, Visual, Command, Search | `diff.close` |
|
||||||
| 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` |
|
||||||
@@ -139,9 +142,13 @@ 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.
|
The diff's changed-file explorer and floating picker use these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's comparison; Working checkout After reuses its editable buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan.
|
||||||
|
|
||||||
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.
|
Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Close the comparison explicitly with `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and also exposes its immutable `close` child; Escape never closes a comparison.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
config.bind "leader d q", config.core.diff.close
|
||||||
|
```
|
||||||
|
|
||||||
Ctrl+h is **not** a Backspace alias or default deletion key in any mode. Raw Ctrl+h (0x08) and Backspace (DEL/0x7f) are distinct, as are CSI-u events. In the tree it navigates in NORMAL and INSERT without deleting query text; it never deletes file-buffer Insert text by default. Explicit `config.core.backspace` bindings remain available if desired.
|
Ctrl+h is **not** a Backspace alias or default deletion key in any mode. Raw Ctrl+h (0x08) and Backspace (DEL/0x7f) are distinct, as are CSI-u events. In the tree it navigates in NORMAL and INSERT without deleting query text; it never deletes file-buffer Insert text by default. Explicit `config.core.backspace` bindings remain available if desired.
|
||||||
|
|
||||||
|
|||||||
+29
-14
@@ -130,7 +130,20 @@ Submitting an empty search repeats the last query. Esc cancels without moving th
|
|||||||
|
|
||||||
## Commands, saving, and pasted input
|
## Commands, saving, and pasted input
|
||||||
|
|
||||||
Commands: `:w [path]`, `:q`, `:q!`, `:wq [path]`, `:plugins`, and `:plugin-command [arguments]`.
|
Command-capable core actions derive canonical Ex names by removing `core.`: `core.quit` gives `:quit`, `core.save` gives `:save`, and `core.diff.close` gives `:diff.close`. Native editing and Insert-only completion/snippet actions remain action bindings, not additional Ex commands.
|
||||||
|
|
||||||
|
Commands: `:save [path]`, `:quit`, `:force_quit`, `:save_and_quit [path]`, `:plugins`, and `:plugin-command [arguments]`. Short names remain aliases of these canonical commands, with identical argument and safety rules:
|
||||||
|
|
||||||
|
| Alias | Canonical command |
|
||||||
|
| --- | --- |
|
||||||
|
| `:w` | `:save` |
|
||||||
|
| `:q` | `:quit` |
|
||||||
|
| `:q!` | `:force_quit` |
|
||||||
|
| `:wq` | `:save_and_quit` |
|
||||||
|
| `:bnext` | `:buffer_next` |
|
||||||
|
| `:bprev` | `:buffer_previous` |
|
||||||
|
| `:bdelete` | `:buffer_delete` |
|
||||||
|
| `:bdelete!` | `:buffer_force_delete` |
|
||||||
|
|
||||||
`:q` refuses unsaved changes in any open buffer; `:q!` exits without saving them. `:wq` saves the active buffer and quits only if every buffer is then clean. Saves use a same-directory temporary file and rename, preserve existing permissions, and reject target symlinks. Writing to a path owned by another loaded buffer refuses before modifying the file. UTF-8 text is edited by extended grapheme; tabs render at four-cell stops. LF and trailing-newline structure are preserved; CRLF and CR are normalized to LF. Binary/terminal-control-containing text is rejected. Bracketed paste inserts only in Insert mode and never executes pasted commands in Normal mode.
|
`:q` refuses unsaved changes in any open buffer; `:q!` exits without saving them. `:wq` saves the active buffer and quits only if every buffer is then clean. Saves use a same-directory temporary file and rename, preserve existing permissions, and reject target symlinks. Writing to a path owned by another loaded buffer refuses before modifying the file. UTF-8 text is edited by extended grapheme; tabs render at four-cell stops. LF and trailing-newline structure are preserved; CRLF and CR are normalized to LF. Binary/terminal-control-containing text is rejected. Bracketed paste inserts only in Insert mode and never executes pasted commands in Normal mode.
|
||||||
|
|
||||||
@@ -151,9 +164,9 @@ One buffer is visible at a time. Each retains its text, path, saved state, direc
|
|||||||
| `:edit PATH` | Switch to an already-loaded file, or read and validate a new buffer; a nonexistent file opens empty |
|
| `:edit PATH` | Switch to an already-loaded file, or read and validate a new buffer; a nonexistent file opens empty |
|
||||||
| `:buffers` | List stable IDs and paths; `%` marks active, `+` marks dirty |
|
| `:buffers` | List stable IDs and paths; `%` marks active, `+` marks dirty |
|
||||||
| `:buffer N` | Switch to buffer ID `N` |
|
| `:buffer N` | Switch to buffer ID `N` |
|
||||||
| `:bnext` / `:bprev` | Switch in ascending-ID order, wrapping at either end |
|
| `:buffer_next` / `:buffer_previous` | Switch in ascending-ID order, wrapping at either end |
|
||||||
| `:bdelete [N]` | Close the active or named buffer; refuse unsaved changes |
|
| `:buffer_delete [N]` | Close the active or named buffer; refuse unsaved changes |
|
||||||
| `:bdelete! [N]` | Explicitly discard changes and close the active or named buffer |
|
| `:buffer_force_delete [N]` | Explicitly discard changes and close the active or named buffer |
|
||||||
|
|
||||||
IDs start at 1 and are never renumbered or reused. Closing the last buffer creates a fresh unnamed clean buffer rather than quitting. Closing the active buffer selects the next remaining ID, with wraparound. Switching does not save, discard, or reload hidden edits. Existing-file aliases resolve to the same loaded buffer; the supplied path spelling remains the save/display path, so opening through a symlink does not bypass save rejection. Paths are relative to the process working directory; the entire `:edit` argument is the path, including spaces, with no shell expansion.
|
IDs start at 1 and are never renumbered or reused. Closing the last buffer creates a fresh unnamed clean buffer rather than quitting. Closing the active buffer selects the next remaining ID, with wraparound. Switching does not save, discard, or reload hidden edits. Existing-file aliases resolve to the same loaded buffer; the supplied path spelling remains the save/display path, so opening through a symlink does not bypass save rejection. Paths are relative to the process working directory; the entire `:edit` argument is the path, including spaces, with no shell expansion.
|
||||||
|
|
||||||
@@ -197,7 +210,7 @@ The project root is the working directory captured at startup. Scanning uses the
|
|||||||
|
|
||||||
Run `:diff` in Normal mode, choose **Before**, then **After**, and press Enter to compare. The command palette also exposes **Diff**; there is no default keybinding. Repository discovery starts at the active file's directory, or the startup project root for an unnamed buffer, and prefers jj over Git.
|
Run `:diff` in Normal mode, choose **Before**, then **After**, and press Enter to compare. The command palette also exposes **Diff**; there is no default keybinding. Repository discovery starts at the active file's directory, or the startup project root for an unnamed buffer, and prefers jj over Git.
|
||||||
|
|
||||||
The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / Branches / Tags** for Git. Type to filter by revision/change ID, commit ID, reference name, or description. **Working checkout** stays first in every category, even when it does not match the query. It compares files on disk, including eligible untracked files; it neither saves nor includes unsaved editor text.
|
The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / Branches / Tags** for Git. Type to filter by revision/change ID, commit ID, reference name, or description. **Working checkout** stays first in every category, even when it does not match the query. As Before it is a disk snapshot, including eligible untracked files. As After it includes unsaved regular-text buffers and enables live editing; opening a comparison never saves them.
|
||||||
|
|
||||||
| Selector input | Effect |
|
| Selector input | Effect |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -205,27 +218,29 @@ The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits /
|
|||||||
| Up / Down, Ctrl+k / Ctrl+j | Previous / next entry |
|
| Up / Down, Ctrl+k / Ctrl+j | Previous / next entry |
|
||||||
| Backspace / Ctrl+u | Remove a query grapheme / clear query |
|
| Backspace / Ctrl+u | Remove a query grapheme / clear query |
|
||||||
| Enter | Choose the entry |
|
| Enter | Choose the entry |
|
||||||
| Esc | After → return to Before; Before → close |
|
| Esc | Cancel loading; After → return to Before; Before → clear query without closing |
|
||||||
|
|
||||||
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.
|
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.
|
The changed-file explorer is **docked on the right**. Modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain the split view. Before and historical/remote After snapshots are read-only. Selecting **Working checkout** as After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. Focus After to use native editing, selections, search, completion, undo/redo, and `:w`. Removed files, rename-only files, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits.
|
||||||
|
|
||||||
**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.
|
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 the new name followed by `← old/path`; directories have no file-status marker. Ordinary file exploration remains unannotated.
|
File rows show colored **A** (added, green), **M** (modified, yellow), **D** (removed, red), or **R** (renamed, cyan) markers in Tree and List views, including filtered results. Rename rows show `R old/path -> new/path`; directories have no file-status marker. Ordinary file exploration remains unannotated.
|
||||||
|
|
||||||
Text panes show change excerpts with **10 unchanged lines above and below** each change, preserving original file line numbers. Overlapping or touching excerpts merge; omitted stretches show an unchanged-line count in both panes. Files with only metadata changes show their notice or “No text changes” rather than unrelated source.
|
Text panes show change excerpts with **10 unchanged lines above and below** each change, preserving original file line numbers. Overlapping or touching excerpts merge; omitted stretches show an unchanged-line count in both panes. Files with only metadata changes show their notice or “No text changes” rather than unrelated source.
|
||||||
|
|
||||||
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.
|
In **Normal mode** with a text pane focused, **`[` expands above** and **`]` expands below**, revealing up to **15 lines** per keypress. Editable After targets the excerpt containing the caret; read-only panes target the excerpt at the top of the viewport. In editable After's Insert mode these keys insert literal text. In read-only panes, 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. Live edits recompute changes while retaining expanded context; the caret stays visible even when the last text change is reverted.
|
||||||
|
|
||||||
|
Editable After navigation stays within the shown source lines. At an excerpt boundary, Up/Down or `j`/`k` jumps directly to the next/previous excerpt, and counts count visible lines rather than omitted lines. This also applies to remapped movement keys, Visual-mode movement, and Insert-mode arrows. Word motions, `gt`/`gb`, search, and occurrence selection use the visible excerpts; `gt`/`gb` target their first/last source line. Hidden short lines do not clamp the caret column. The viewport retains its vertical and horizontal offsets when movement reverses, scrolling only when the caret crosses a visible edge, as in a normal buffer. Moving or restoring a caret never reveals omitted context: only Normal-mode `[`/`]` expands existing excerpts. Edits still recompute changed regions and retain previously expanded context.
|
||||||
|
|
||||||
Configure these counts in Ruby with `config.diff_context_lines = 10` (nonnegative; `0` shows changes only) and `config.diff_expand_lines = 15` (positive).
|
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.
|
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). Editable After also uses its live buffer's semantic highlighting, selections, and diagnostics. 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.
|
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.
|
In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Read-only text panes use Up/Down or Ctrl+j/k for shared vertical scrolling, Left/Right or Ctrl+h/l for independent horizontal scrolling, and Home/End for the first/last displayed row; editable After uses normal buffer motions. Wheel input over text panes scrolls the visible panes without moving the editable caret; over Files it navigates visible tree rows without opening them. Escape cancels modes, prompts, or filtering, or returns picker focus to the first visible text pane; **it never closes the diff**. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias.
|
||||||
|
|
||||||
Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file.
|
Binary data, missing final newlines, permission/type changes, symlink targets, and submodule pointers have explicit notices. Symlinks are not followed and submodule contents are not traversed. jj conflicts use jj's file-content projection and carry conflict notices; ordinary files remain readable. Identical-content moves are shown as renames; modified moves appear as an added and removed file.
|
||||||
|
|
||||||
@@ -233,9 +248,9 @@ Binary data, missing final newlines, permission/type changes, symlink targets, a
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Search is case-insensitive: every whitespace-separated query token must occur in the action/command name, built-in Ex alias, human-readable label, or owner. Results have a stable label/name order. Ctrl+j/Down/Ctrl+n select the next result; Ctrl+k/Up select the previous, with wraparound. Backspace removes a whole grapheme. Enter with no match or an ineligible selection leaves the palette open. Bracketed paste is ignored while the palette is open.
|
Search is case-insensitive: every whitespace-separated query token must occur in the action/command name, canonical Ex name, a shorthand alias, human-readable label, or owner. Aliases find the same action rather than creating duplicate entries. Results have a stable label/name order. Ctrl+j/Down/Ctrl+n select the next result; Ctrl+k/Up select the previous, with wraparound. Backspace removes a whole grapheme. Enter with no match or an ineligible selection leaves the palette open. Bracketed paste is ignored while the palette is open.
|
||||||
|
|
||||||
Accepting a core action executes it in the underlying mode, including Visual edits and Insert-mode actions. `core.edit` and `core.buffer` instead open prefilled Ex prompts for their required arguments. The destructive `core.force_quit` (`q!`) and `core.buffer_force_delete` (`bdelete!`) actions also open Ex prompts, requiring another Enter before discarding changes. Accepting a plugin command is available only in Normal mode and opens a prefilled `:` prompt; add arguments if needed, then Enter executes through the usual session dispatch.
|
Accepting a core action executes it in the underlying mode, including Visual edits and Insert-mode actions. `core.edit` and `core.buffer` instead open prefilled Ex prompts for their required arguments. The destructive `core.force_quit` and `core.buffer_force_delete` actions also open canonical Ex prompts, requiring another Enter before discarding changes. Accepting a plugin command is available only in Normal mode and opens a prefilled `:` prompt; add arguments if needed, then Enter executes through the usual session dispatch.
|
||||||
|
|
||||||
Esc or Ctrl+p cancels without changing text, selections, the previous command/search prompt, or pending Insert history. The opening key can be remapped with `config.core.palette`; list navigation uses the shared configurable `selection` context. Other popup controls keep their defaults unless explicitly intercepted by a selection binding. The TUI uses a constraint-based modal panel, keeps the selected result visible, and prioritizes the query on tiny terminals.
|
Esc or Ctrl+p cancels without changing text, selections, the previous command/search prompt, or pending Insert history. The opening key can be remapped with `config.core.palette`; list navigation uses the shared configurable `selection` context. Other popup controls keep their defaults unless explicitly intercepted by a selection binding. The TUI uses a constraint-based modal panel, keeps the selected result visible, and prioritizes the query on tiny terminals.
|
||||||
|
|
||||||
|
|||||||
+737
-166
File diff suppressed because it is too large
Load Diff
+975
-113
File diff suppressed because it is too large
Load Diff
+19
-10
@@ -165,10 +165,10 @@ impl FilePicker {
|
|||||||
Ok(Self::from_nodes(root, nodes, presentation, view, warning))
|
Ok(Self::from_nodes(root, nodes, presentation, view, warning))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the changed-path catalog without consulting the working tree.
|
/// Builds a changed-path catalog, retaining each path's comparison index.
|
||||||
pub fn from_paths(root: PathBuf, paths: impl IntoIterator<Item = PathBuf>) -> Self {
|
pub fn from_paths(root: PathBuf, paths: impl IntoIterator<Item = (usize, PathBuf)>) -> Self {
|
||||||
let mut catalog = HashMap::new();
|
let mut catalog = HashMap::new();
|
||||||
for (index, path) in paths.into_iter().enumerate() {
|
for (index, path) in paths {
|
||||||
for parent in path
|
for parent in path
|
||||||
.ancestors()
|
.ancestors()
|
||||||
.skip(1)
|
.skip(1)
|
||||||
@@ -719,7 +719,17 @@ impl FilePicker {
|
|||||||
|
|
||||||
pub(crate) fn refresh(&mut self) {
|
pub(crate) fn refresh(&mut self) {
|
||||||
match Self::scan(self.root.clone(), self.presentation, self.view) {
|
match Self::scan(self.root.clone(), self.presentation, self.view) {
|
||||||
Ok(mut replacement) => {
|
Ok(replacement) => self.replace_catalog(replacement),
|
||||||
|
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn replace_paths(&mut self, paths: impl IntoIterator<Item = (usize, PathBuf)>) {
|
||||||
|
self.replace_catalog(Self::from_paths(self.root.clone(), paths));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_catalog(&mut self, mut replacement: Self) {
|
||||||
|
replacement.presentation = self.presentation;
|
||||||
let by_path: HashMap<_, _> = replacement
|
let by_path: HashMap<_, _> = replacement
|
||||||
.nodes
|
.nodes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -755,8 +765,7 @@ impl FilePicker {
|
|||||||
.filter(|&node| replacement.nodes[node].is_dir)
|
.filter(|&node| replacement.nodes[node].is_dir)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
replacement.saved_selection =
|
replacement.saved_selection = self.saved_selection.map(|node| node.and_then(surviving));
|
||||||
self.saved_selection.map(|node| node.and_then(surviving));
|
|
||||||
replacement.filtered_selection =
|
replacement.filtered_selection =
|
||||||
self.filtered_selection.map(|node| node.and_then(surviving));
|
self.filtered_selection.map(|node| node.and_then(surviving));
|
||||||
replacement.query = self.query.clone();
|
replacement.query = self.query.clone();
|
||||||
@@ -773,9 +782,6 @@ impl FilePicker {
|
|||||||
replacement.remember_selection(false);
|
replacement.remember_selection(false);
|
||||||
*self = replacement;
|
*self = replacement;
|
||||||
}
|
}
|
||||||
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -786,7 +792,10 @@ mod tests {
|
|||||||
fn virtual_catalog_compacts_and_retains_file_directory_collisions() {
|
fn virtual_catalog_compacts_and_retains_file_directory_collisions() {
|
||||||
let mut picker = FilePicker::from_paths(
|
let mut picker = FilePicker::from_paths(
|
||||||
PathBuf::new(),
|
PathBuf::new(),
|
||||||
["x", "x/y", "src/deep/deleted.rs", "src/deep/deleted.rs"].map(PathBuf::from),
|
["x", "x/y", "src/deep/deleted.rs", "src/deep/deleted.rs"]
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.into_iter()
|
||||||
|
.enumerate(),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
picker
|
picker
|
||||||
|
|||||||
+11
-6
@@ -36,14 +36,19 @@ Insert: Ctrl+n requests LSP completion at primary caret, then cycles next; Ctrl+
|
|||||||
Snippets expand fully: type replaces argument, Tab/Shift+Tab next/previous, final $0 ends
|
Snippets expand fully: type replaces argument, Tab/Shift+Tab next/previous, final $0 ends
|
||||||
Outside snippets Enter inserts newline, Tab inserts tab; Esc keeps text and returns to Normal
|
Outside snippets Enter inserts newline, Tab inserts tab; Esc keeps text and returns to Normal
|
||||||
Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps)
|
Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps)
|
||||||
Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]
|
Commands: :save [PATH], :quit, :force_quit, :save_and_quit [PATH], :plugins, :PLUGIN [ARGS]
|
||||||
Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N]
|
Aliases: w=save, q=quit, q!=force_quit, wq=save_and_quit
|
||||||
:q refuses any dirty buffer; :wq saves active and quits only if all are clean
|
Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffer_delete [N], :buffer_force_delete [N]
|
||||||
|
Aliases: bnext, bprev, bdelete, bdelete!; arguments and safety rules are unchanged
|
||||||
|
:quit refuses any dirty buffer; :save_and_quit 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
|
Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers
|
||||||
Tab/Shift+Tab categories/focus; type to search IDs/refs; arrows navigate/scroll; Esc back/close
|
Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus
|
||||||
|
Type to search IDs/refs; navigation skips omitted lines without expanding excerpts
|
||||||
|
Normal [/] explicitly expands context; Insert brackets remain literal text
|
||||||
|
:diff.close exits explicitly; Esc cancels modes/prompts/filtering without closing the diff
|
||||||
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
||||||
Changed-file tree: Ctrl+e hides/shows; A added, M modified, D removed, R renamed (old path shown)
|
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
+87
-68
@@ -1,7 +1,7 @@
|
|||||||
//! Read-only active-repository discovery and isolated revision comparison.
|
//! Read-only active-repository discovery and isolated revision comparison.
|
||||||
use crate::diff::{
|
use crate::diff::{
|
||||||
Catalog, Category, ChangedFile, Comparison, FileStatus, Hunk, RepoKind, Revision,
|
Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision, RevisionTarget,
|
||||||
RevisionTarget,
|
line_hunks,
|
||||||
};
|
};
|
||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeMap, BTreeSet},
|
collections::{BTreeMap, BTreeSet},
|
||||||
@@ -934,7 +934,7 @@ impl Repository {
|
|||||||
}
|
}
|
||||||
Ok(snapshot)
|
Ok(snapshot)
|
||||||
}
|
}
|
||||||
fn working_snapshot(&self) -> Result<Snapshot, String> {
|
fn working_snapshot(&self, buffers: &[(PathBuf, String)]) -> Result<Snapshot, String> {
|
||||||
let mut tracked = BTreeSet::new();
|
let mut tracked = BTreeSet::new();
|
||||||
let mut submodules = BTreeSet::new();
|
let mut submodules = BTreeSet::new();
|
||||||
if self.kind == RepoKind::Git {
|
if self.kind == RepoKind::Git {
|
||||||
@@ -1017,6 +1017,32 @@ impl Repository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let overlays: BTreeMap<_, _> = buffers
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(path, text)| {
|
||||||
|
let relative = path.strip_prefix(&self.root).ok()?;
|
||||||
|
if safe_path(relative).is_err()
|
||||||
|
|| !path.is_absolute()
|
||||||
|
|| text.as_bytes().contains(&0)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Reject symlinks in every component, not just the final file.
|
||||||
|
let mut full = self.root.clone();
|
||||||
|
for component in relative.components() {
|
||||||
|
full.push(component);
|
||||||
|
let metadata = fs::symlink_metadata(&full).ok()?;
|
||||||
|
if metadata.file_type().is_symlink() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs::symlink_metadata(&full)
|
||||||
|
.ok()?
|
||||||
|
.is_file()
|
||||||
|
.then_some((relative.to_path_buf(), text))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
paths.extend(overlays.keys().cloned());
|
||||||
let mut total = 0;
|
let mut total = 0;
|
||||||
let mut snapshot = BTreeMap::new();
|
let mut snapshot = BTreeMap::new();
|
||||||
for path in paths {
|
for path in paths {
|
||||||
@@ -1110,6 +1136,20 @@ impl Repository {
|
|||||||
path.display()
|
path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let bytes = if !bytes.contains(&0)
|
||||||
|
&& std::str::from_utf8(&bytes).is_ok()
|
||||||
|
&& let Some(text) = overlays.get(&path)
|
||||||
|
{
|
||||||
|
if text.len() > FILE_LIMIT {
|
||||||
|
return Err(format!(
|
||||||
|
"{} exceeds the 16 MiB comparison file limit",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
text.as_bytes().to_vec()
|
||||||
|
} else {
|
||||||
|
bytes
|
||||||
|
};
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
let executable = {
|
let executable = {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
@@ -1146,16 +1186,24 @@ impl Repository {
|
|||||||
}
|
}
|
||||||
fn snapshot(&mut self, target: &RevisionTarget) -> Result<Snapshot, String> {
|
fn snapshot(&mut self, target: &RevisionTarget) -> Result<Snapshot, String> {
|
||||||
if *target == RevisionTarget::Working {
|
if *target == RevisionTarget::Working {
|
||||||
self.working_snapshot()
|
self.working_snapshot(&[])
|
||||||
} else {
|
} else {
|
||||||
let (store, id) = self.resolve(target)?;
|
let (store, id) = self.resolve(target)?;
|
||||||
self.commit_snapshot(&store, &id)
|
self.commit_snapshot(&store, &id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn compare(&mut self, before: &Revision, after: &Revision) -> Result<Comparison, String> {
|
pub fn compare(
|
||||||
|
&mut self,
|
||||||
|
before: &Revision,
|
||||||
|
after: &Revision,
|
||||||
|
buffers: &[(PathBuf, String)],
|
||||||
|
) -> Result<Comparison, String> {
|
||||||
let left = self.snapshot(&before.target)?;
|
let left = self.snapshot(&before.target)?;
|
||||||
let right = if before.target == after.target {
|
let working_after = after.target == RevisionTarget::Working;
|
||||||
|
let right = if before.target == after.target && (!working_after || buffers.is_empty()) {
|
||||||
None
|
None
|
||||||
|
} else if working_after {
|
||||||
|
Some(self.working_snapshot(buffers)?)
|
||||||
} else {
|
} else {
|
||||||
Some(self.snapshot(&after.target)?)
|
Some(self.snapshot(&after.target)?)
|
||||||
};
|
};
|
||||||
@@ -1199,15 +1247,36 @@ impl Repository {
|
|||||||
removed.insert(j);
|
removed.insert(j);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let files = files
|
let files: Vec<_> = files
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(i, f)| (!removed.contains(&i)).then_some(f))
|
.filter_map(|(i, f)| (!removed.contains(&i)).then_some(f))
|
||||||
.collect();
|
.collect();
|
||||||
|
let editable_paths = if working_after {
|
||||||
|
files
|
||||||
|
.iter()
|
||||||
|
.filter(|file| {
|
||||||
|
right.get(&file.path).is_some_and(|data| {
|
||||||
|
matches!(data.mode.as_str(), "100644" | "100755")
|
||||||
|
&& !data.bytes.contains(&0)
|
||||||
|
&& std::str::from_utf8(&data.bytes).is_ok()
|
||||||
|
&& file
|
||||||
|
.notice
|
||||||
|
.as_deref()
|
||||||
|
.is_none_or(|notice| !notice.contains("Binary or non-UTF-8"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map(|file| file.path.clone())
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
BTreeSet::new()
|
||||||
|
};
|
||||||
Ok(Comparison {
|
Ok(Comparison {
|
||||||
before: before.label.clone(),
|
before: before.label.clone(),
|
||||||
after: after.label.clone(),
|
after: after.label.clone(),
|
||||||
files,
|
files,
|
||||||
|
root: self.root.clone(),
|
||||||
|
editable_paths,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
fn changed_file(
|
fn changed_file(
|
||||||
@@ -1253,38 +1322,15 @@ impl Repository {
|
|||||||
eof_label(after)
|
eof_label(after)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let before_path = self.temporary.0.join("before");
|
let before_lines = lines(a);
|
||||||
let after_path = self.temporary.0.join("after");
|
let after_lines = lines(b);
|
||||||
fs::write(&before_path, a).map_err(|e| e.to_string())?;
|
let hunks = line_hunks(
|
||||||
fs::write(&after_path, b).map_err(|e| e.to_string())?;
|
&before_lines,
|
||||||
let mut cmd = Command::new("git");
|
a.ends_with(b"\n"),
|
||||||
cmd.args([
|
&after_lines,
|
||||||
"--no-pager",
|
b.ends_with(b"\n"),
|
||||||
"diff",
|
);
|
||||||
"--no-index",
|
(before_lines, after_lines, hunks)
|
||||||
"--no-color",
|
|
||||||
"--no-ext-diff",
|
|
||||||
"--no-textconv",
|
|
||||||
"--text",
|
|
||||||
"--unified=0",
|
|
||||||
"--",
|
|
||||||
])
|
|
||||||
.arg(&before_path)
|
|
||||||
.arg(&after_path)
|
|
||||||
.current_dir(&self.temporary.0);
|
|
||||||
let output = run(cmd, vec![], &self.cancelled)?;
|
|
||||||
if !matches!(output.code, Some(0 | 1)) {
|
|
||||||
return Err(format!(
|
|
||||||
"Cannot calculate diff hunks: {}",
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let patch = text(output.stdout)?;
|
|
||||||
let mut hunks = vec![];
|
|
||||||
for line in patch.lines().filter(|l| l.starts_with("@@ ")) {
|
|
||||||
hunks.push(parse_hunk(line)?);
|
|
||||||
}
|
|
||||||
(lines(a), lines(b), hunks)
|
|
||||||
};
|
};
|
||||||
notices.dedup();
|
notices.dedup();
|
||||||
Ok(ChangedFile {
|
Ok(ChangedFile {
|
||||||
@@ -1293,6 +1339,8 @@ impl Repository {
|
|||||||
status,
|
status,
|
||||||
before: before_lines,
|
before: before_lines,
|
||||||
after: after_lines,
|
after: after_lines,
|
||||||
|
before_eof: a.ends_with(b"\n"),
|
||||||
|
after_eof: b.ends_with(b"\n"),
|
||||||
hunks,
|
hunks,
|
||||||
notice: if notices.is_empty() {
|
notice: if notices.is_empty() {
|
||||||
None
|
None
|
||||||
@@ -1318,35 +1366,6 @@ fn eof_label(file: Option<&FileData>) -> &'static str {
|
|||||||
Some(_) => "missing",
|
Some(_) => "missing",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn parse_hunk(line: &str) -> Result<Hunk, String> {
|
|
||||||
fn range(value: &str) -> Result<(usize, usize), String> {
|
|
||||||
let (start, len) = value[1..].split_once(',').unwrap_or((&value[1..], "1"));
|
|
||||||
let start: usize = start.parse().map_err(|_| "Invalid diff hunk start")?;
|
|
||||||
let len: usize = len.parse().map_err(|_| "Invalid diff hunk length")?;
|
|
||||||
Ok((
|
|
||||||
if len == 0 {
|
|
||||||
start
|
|
||||||
} else {
|
|
||||||
start
|
|
||||||
.checked_sub(1)
|
|
||||||
.ok_or("Invalid nonempty diff hunk start")?
|
|
||||||
},
|
|
||||||
len,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
let mut fields = line.split_whitespace();
|
|
||||||
if fields.next() != Some("@@") {
|
|
||||||
return Err("Invalid diff hunk header".into());
|
|
||||||
}
|
|
||||||
let (before_start, before_len) = range(fields.next().ok_or("Missing Before hunk range")?)?;
|
|
||||||
let (after_start, after_len) = range(fields.next().ok_or("Missing After hunk range")?)?;
|
|
||||||
Ok(Hunk {
|
|
||||||
before_start,
|
|
||||||
before_len,
|
|
||||||
after_start,
|
|
||||||
after_len,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(test, unix))]
|
#[cfg(all(test, unix))]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
+57
-1
@@ -516,6 +516,7 @@ impl Host {
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct Startup<'a> {
|
struct Startup<'a> {
|
||||||
core_actions: crate::editor::CoreActionCatalog,
|
core_actions: crate::editor::CoreActionCatalog,
|
||||||
|
command_aliases: crate::editor::CoreCommandAliases,
|
||||||
options: &'a serde_json::Value,
|
options: &'a serde_json::Value,
|
||||||
}
|
}
|
||||||
let mut encoded_startup = FrameWriter(Vec::new());
|
let mut encoded_startup = FrameWriter(Vec::new());
|
||||||
@@ -523,6 +524,7 @@ impl Host {
|
|||||||
&mut encoded_startup,
|
&mut encoded_startup,
|
||||||
&Startup {
|
&Startup {
|
||||||
core_actions: crate::editor::CoreActionCatalog,
|
core_actions: crate::editor::CoreActionCatalog,
|
||||||
|
command_aliases: crate::editor::CoreCommandAliases,
|
||||||
options,
|
options,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -933,8 +935,19 @@ end
|
|||||||
config.bind "V", config.core.buffer_previous
|
config.bind "V", config.core.buffer_previous
|
||||||
config.bind "D", config.core.buffer_delete
|
config.bind "D", config.core.buffer_delete
|
||||||
config.bind "Z", config.core.buffer_force_delete
|
config.bind "Z", config.core.buffer_force_delete
|
||||||
|
config.bind "R", config.core.diff
|
||||||
|
config.bind "T", config.core.diff.close
|
||||||
|
raise "mutable diff actions" unless config.core.frozen? && config.core.diff.frozen? &&
|
||||||
|
config.core.diff.close.frozen? && config.core.diff.name.frozen? &&
|
||||||
|
config.core.diff.close.name.frozen?
|
||||||
end"#,
|
end"#,
|
||||||
);
|
);
|
||||||
|
let mut review = EditorSession::open(None, vec!["text".into()], Some(&config)).unwrap();
|
||||||
|
review.handle(Input::Char('R'));
|
||||||
|
review.handle(Input::Escape);
|
||||||
|
assert!(review.editor().diff().is_some());
|
||||||
|
review.handle(Input::Char('T'));
|
||||||
|
assert!(review.editor().diff().is_none());
|
||||||
for (key, dirty, quit, saved) in [
|
for (key, dirty, quit, saved) in [
|
||||||
('Q', false, true, false),
|
('Q', false, true, false),
|
||||||
('Q', true, false, false),
|
('Q', true, false, false),
|
||||||
@@ -1007,7 +1020,20 @@ end"#,
|
|||||||
#[test]
|
#[test]
|
||||||
fn raw_builtin_aliases_are_not_external_binding_commands() {
|
fn raw_builtin_aliases_are_not_external_binding_commands() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
for name in ["q", "core.quit"] {
|
for name in [
|
||||||
|
"quit",
|
||||||
|
"save",
|
||||||
|
"buffer_next",
|
||||||
|
"q",
|
||||||
|
"w",
|
||||||
|
"bnext",
|
||||||
|
"q!",
|
||||||
|
"wq",
|
||||||
|
"bprev",
|
||||||
|
"bdelete",
|
||||||
|
"bdelete!",
|
||||||
|
"core.quit",
|
||||||
|
] {
|
||||||
let config = fixture.file(
|
let config = fixture.file(
|
||||||
"config.rb",
|
"config.rb",
|
||||||
&format!("Vamp.configure {{ |config| config.bind 'K', '{name}' }}"),
|
&format!("Vamp.configure {{ |config| config.bind 'K', '{name}' }}"),
|
||||||
@@ -1016,6 +1042,33 @@ end"#,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_commands_cannot_shadow_canonical_commands_or_aliases() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
for name in [
|
||||||
|
"quit",
|
||||||
|
"save",
|
||||||
|
"buffer_next",
|
||||||
|
"q",
|
||||||
|
"w",
|
||||||
|
"bnext",
|
||||||
|
"q!",
|
||||||
|
"wq",
|
||||||
|
"bprev",
|
||||||
|
"bdelete",
|
||||||
|
"bdelete!",
|
||||||
|
] {
|
||||||
|
let path = fixture.file(
|
||||||
|
"plugin.rb",
|
||||||
|
&format!("Vamp.plugin 'shadow'\nVamp.command '{name}' do |editor, args|\nend"),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
RubyPlugin::start(&path, &serde_json::json!({})).is_err(),
|
||||||
|
"plugin shadowed built-in command {name}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn config_resolves_relative_paths_and_rejects_invalid_types_and_core() {
|
fn config_resolves_relative_paths_and_rejects_invalid_types_and_core() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
@@ -1047,6 +1100,9 @@ end
|
|||||||
"config.core.lsp",
|
"config.core.lsp",
|
||||||
"config.core.lsp.hover.tap { |action| action.name = 'core.delete' }",
|
"config.core.lsp.hover.tap { |action| action.name = 'core.delete' }",
|
||||||
"config.core.lsp.tap { |namespace| namespace.define_singleton_method(:bogus) { namespace.hover } }.bogus",
|
"config.core.lsp.tap { |namespace| namespace.define_singleton_method(:bogus) { namespace.hover } }.bogus",
|
||||||
|
"config.core.diff.tap { |action| action.name = 'core.delete' }",
|
||||||
|
"config.core.diff.tap { |action| action.define_singleton_method(:bogus) { action.close } }.bogus",
|
||||||
|
"config.core.diff.close.tap { |action| action.name = 'core.delete' }",
|
||||||
] {
|
] {
|
||||||
let invalid = fixture.file(
|
let invalid = fixture.file(
|
||||||
"invalid_action.rb",
|
"invalid_action.rb",
|
||||||
|
|||||||
+3
-2
@@ -7,7 +7,7 @@ module Vamp
|
|||||||
# @param startup [Object] action catalog and options
|
# @param startup [Object] action catalog and options
|
||||||
# @return [void]
|
# @return [void]
|
||||||
def self.startup!(startup)
|
def self.startup!(startup)
|
||||||
return if hash_shape?(startup, %w[core_actions options]) && startup["options"].is_a?(Hash)
|
return if hash_shape?(startup, %w[command_aliases core_actions options]) && startup["options"].is_a?(Hash)
|
||||||
|
|
||||||
raise TypeError, "invalid Ruby startup catalog/options"
|
raise TypeError, "invalid Ruby startup catalog/options"
|
||||||
end
|
end
|
||||||
@@ -100,7 +100,7 @@ module Vamp
|
|||||||
# @return [Proc, Method] the installed sender
|
# @return [Proc, Method] the installed sender
|
||||||
def self.setup(mode, startup, sender)
|
def self.setup(mode, startup, sender)
|
||||||
FrameValidation.startup!(startup)
|
FrameValidation.startup!(startup)
|
||||||
install_actions(startup["core_actions"])
|
install_actions(startup["core_actions"], startup["command_aliases"])
|
||||||
@mode = mode
|
@mode = mode
|
||||||
reset_registrations
|
reset_registrations
|
||||||
@options = deep_freeze(json_value!(startup["options"]))
|
@options = deep_freeze(json_value!(startup["options"]))
|
||||||
@@ -168,6 +168,7 @@ module Vamp
|
|||||||
raise ArgumentError, "declare a plugin before its commands" unless @mode == "plugin" && @name
|
raise ArgumentError, "declare a plugin before its commands" unless @mode == "plugin" && @name
|
||||||
|
|
||||||
name = registration_name!(name, "command")
|
name = registration_name!(name, "command")
|
||||||
|
raise ArgumentError, "built-in command #{name} cannot be replaced" if Vamp.builtin_command?(name)
|
||||||
raise ArgumentError, "command requires a block" unless callback
|
raise ArgumentError, "command requires a block" unless callback
|
||||||
raise ArgumentError, "duplicate command #{name}" if @commands.key?(name)
|
raise ArgumentError, "duplicate command #{name}" if @commands.key?(name)
|
||||||
|
|
||||||
|
|||||||
+29
-17
@@ -58,26 +58,37 @@ module Vamp
|
|||||||
# @type [Module<Vamp::Core>]
|
# @type [Module<Vamp::Core>]
|
||||||
Core = Module.new
|
Core = Module.new
|
||||||
|
|
||||||
# @param entries [Array<Hash{String => String, nil}>] action name and optional Ex alias descriptors
|
# @param entries [Array<Hash{String => String, nil}>] action name and optional canonical Ex name
|
||||||
# @return [Array<Module>] frozen action namespaces
|
# @param command_aliases [Hash{String => String}] shorthand to canonical commands
|
||||||
def self.install_actions(entries)
|
# @return [Array<Module, Vamp::Action>] frozen action namespaces and leaves
|
||||||
|
def self.install_actions(entries, command_aliases)
|
||||||
raise TypeError, "core action catalog must be an Array" unless entries.is_a?(Array)
|
raise TypeError, "core action catalog must be an Array" unless entries.is_a?(Array)
|
||||||
|
raise TypeError, "command aliases must be a Hash" unless command_aliases.is_a?(Hash)
|
||||||
|
|
||||||
namespaces = [Core]
|
namespaces = [Core]
|
||||||
aliases = {}
|
commands = {}
|
||||||
entries.each do |entry|
|
entries.sort_by { |entry| entry.fetch("name").count(".") }.each do |entry|
|
||||||
raise TypeError, "core action descriptor must be a Hash" unless entry.is_a?(Hash)
|
raise TypeError, "core action descriptor must be a Hash" unless entry.is_a?(Hash)
|
||||||
|
|
||||||
install_action(entry.fetch("name"), namespaces)
|
install_action(entry.fetch("name"), namespaces)
|
||||||
ex = entry.fetch("ex")
|
ex = entry.fetch("ex")
|
||||||
aliases[string!(ex, "core Ex alias").freeze] = true unless ex.nil?
|
canonical = string!(ex, "core Ex command").freeze unless ex.nil?
|
||||||
|
commands[canonical] = canonical unless ex.nil?
|
||||||
end
|
end
|
||||||
@builtin_commands = aliases.freeze
|
canonical_commands = commands.keys
|
||||||
|
command_aliases.each do |name, canonical|
|
||||||
|
name = string!(name, "command alias").freeze
|
||||||
|
canonical = string!(canonical, "canonical command").freeze
|
||||||
|
raise ArgumentError, "unknown canonical command: #{canonical}" unless canonical_commands.include?(canonical)
|
||||||
|
|
||||||
|
commands[name] = canonical
|
||||||
|
end
|
||||||
|
@builtin_commands = commands.freeze
|
||||||
namespaces.each(&:freeze)
|
namespaces.each(&:freeze)
|
||||||
end
|
end
|
||||||
|
|
||||||
# @param name [String] binding command name
|
# @param name [String] binding command name
|
||||||
# @return [Boolean] whether the catalog owns this textual Ex alias
|
# @return [Boolean] whether the catalog owns this canonical command or shorthand
|
||||||
def self.builtin_command?(name)
|
def self.builtin_command?(name)
|
||||||
@builtin_commands.key?(name)
|
@builtin_commands.key?(name)
|
||||||
end
|
end
|
||||||
@@ -139,7 +150,7 @@ module Vamp
|
|||||||
end
|
end
|
||||||
|
|
||||||
# @param name [String]
|
# @param name [String]
|
||||||
# @param namespaces [Array<Module>]
|
# @param namespaces [Array<Module, Vamp::Action>]
|
||||||
# @return [Symbol] the installed action method name
|
# @return [Symbol] the installed action method name
|
||||||
def self.install_action(name, namespaces)
|
def self.install_action(name, namespaces)
|
||||||
name = action_name(name)
|
name = action_name(name)
|
||||||
@@ -147,7 +158,8 @@ module Vamp
|
|||||||
namespace = parts[0...-1].reduce(Core) { |parent, part| action_namespace(parent, part, namespaces, name) }
|
namespace = parts[0...-1].reduce(Core) { |parent, part| action_namespace(parent, part, namespaces, name) }
|
||||||
leaf = parts.last
|
leaf = parts.last
|
||||||
available_action!(namespace, leaf, name)
|
available_action!(namespace, leaf, name)
|
||||||
action = Action.send(:new, name.freeze).freeze
|
action = Action.send(:new, name.freeze)
|
||||||
|
namespaces << action
|
||||||
namespace.define_singleton_method(leaf) { action }
|
namespace.define_singleton_method(leaf) { action }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -160,23 +172,23 @@ module Vamp
|
|||||||
name
|
name
|
||||||
end
|
end
|
||||||
|
|
||||||
# @param parent [Module]
|
# @param parent [Module, Vamp::Action]
|
||||||
# @param part [String]
|
# @param part [String]
|
||||||
# @param namespaces [Array<Module>]
|
# @param namespaces [Array<Module, Vamp::Action>]
|
||||||
# @param name [String]
|
# @param name [String]
|
||||||
# @return [Module]
|
# @return [Module, Vamp::Action]
|
||||||
def self.action_namespace(parent, part, namespaces, name)
|
def self.action_namespace(parent, part, namespaces, name)
|
||||||
return new_action_namespace(parent, part, namespaces) unless parent.singleton_methods(false).include?(part.to_sym)
|
return new_action_namespace(parent, part, namespaces) unless parent.singleton_methods(false).include?(part.to_sym)
|
||||||
|
|
||||||
child = parent.public_send(part)
|
child = parent.public_send(part)
|
||||||
raise ArgumentError, "conflicting core action namespace: #{name}" unless child.is_a?(Module)
|
raise ArgumentError, "conflicting core action namespace: #{name}" unless child.is_a?(Module) || child.is_a?(Action)
|
||||||
|
|
||||||
child
|
child
|
||||||
end
|
end
|
||||||
|
|
||||||
# @param parent [Module]
|
# @param parent [Module, Vamp::Action]
|
||||||
# @param part [String]
|
# @param part [String]
|
||||||
# @param namespaces [Array<Module>]
|
# @param namespaces [Array<Module, Vamp::Action>]
|
||||||
# @return [Module]
|
# @return [Module]
|
||||||
def self.new_action_namespace(parent, part, namespaces)
|
def self.new_action_namespace(parent, part, namespaces)
|
||||||
child = Module.new
|
child = Module.new
|
||||||
@@ -185,7 +197,7 @@ module Vamp
|
|||||||
child
|
child
|
||||||
end
|
end
|
||||||
|
|
||||||
# @param namespace [Module]
|
# @param namespace [Module, Vamp::Action]
|
||||||
# @param leaf [String]
|
# @param leaf [String]
|
||||||
# @param name [String]
|
# @param name [String]
|
||||||
# @return [nil]
|
# @return [nil]
|
||||||
|
|||||||
+162
-26
@@ -11,7 +11,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
diff::{Operation as DiffOperation, Outcome as DiffOutcome},
|
diff::{Focus, 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::{
|
||||||
@@ -71,8 +71,12 @@ impl DiffWorker {
|
|||||||
DiffOperation::FetchRemote(remote) => {
|
DiffOperation::FetchRemote(remote) => {
|
||||||
repository.fetch_remote(&remote).map(DiffOutcome::Catalog)
|
repository.fetch_remote(&remote).map(DiffOutcome::Catalog)
|
||||||
}
|
}
|
||||||
DiffOperation::Compare { before, after } => repository
|
DiffOperation::Compare {
|
||||||
.compare(&before, &after)
|
before,
|
||||||
|
after,
|
||||||
|
buffers,
|
||||||
|
} => repository
|
||||||
|
.compare(&before, &after, &buffers)
|
||||||
.map(DiffOutcome::Comparison),
|
.map(DiffOutcome::Comparison),
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -254,6 +258,21 @@ impl EditorSession {
|
|||||||
&self.editor
|
&self.editor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_diff_viewport(
|
||||||
|
&mut self,
|
||||||
|
comparison_id: u64,
|
||||||
|
file: usize,
|
||||||
|
top: usize,
|
||||||
|
left_after: usize,
|
||||||
|
) {
|
||||||
|
self.editor
|
||||||
|
.set_diff_viewport(comparison_id, file, top, left_after);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn scroll_diff(&mut self, focus: Focus, down: bool) {
|
||||||
|
self.editor.scroll_diff(focus, down);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn handle(&mut self, input: Input) {
|
pub fn handle(&mut self, input: Input) {
|
||||||
self.cancel_pending_popup_request();
|
self.cancel_pending_popup_request();
|
||||||
let invocation = self.editor.handle(input);
|
let invocation = self.editor.handle(input);
|
||||||
@@ -386,6 +405,7 @@ impl EditorSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let context = self.editor.context();
|
let context = self.editor.context();
|
||||||
|
let read_only = self.editor.diff().is_some() && !self.editor.diff_editing();
|
||||||
let result = match self.plugins.get_mut(&invocation.plugin) {
|
let result = match self.plugins.get_mut(&invocation.plugin) {
|
||||||
Some(plugin) if plugin.is_async() => {
|
Some(plugin) if plugin.is_async() => {
|
||||||
let requests = self.requests.entry(invocation.plugin.clone()).or_default();
|
let requests = self.requests.entry(invocation.plugin.clone()).or_default();
|
||||||
@@ -402,7 +422,18 @@ impl EditorSession {
|
|||||||
}
|
}
|
||||||
Some(plugin) => plugin
|
Some(plugin) => plugin
|
||||||
.invoke(&invocation.command, &invocation.args, &context)
|
.invoke(&invocation.command, &invocation.args, &context)
|
||||||
.and_then(|effects| self.editor.apply(effects)),
|
.and_then(|effects| {
|
||||||
|
if read_only
|
||||||
|
&& effects
|
||||||
|
.iter()
|
||||||
|
.any(|effect| matches!(effect, Effect::Insert(_) | Effect::Replace(_)))
|
||||||
|
{
|
||||||
|
self.editor.set_message("Diff pane is read-only");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
self.editor.apply(effects)
|
||||||
|
}
|
||||||
|
}),
|
||||||
None => Err("plugin host is unavailable".into()),
|
None => Err("plugin host is unavailable".into()),
|
||||||
};
|
};
|
||||||
if let Err(error) = result {
|
if let Err(error) = result {
|
||||||
@@ -555,7 +586,7 @@ impl EditorSession {
|
|||||||
request_id: Option<u64>,
|
request_id: Option<u64>,
|
||||||
source: InvocationSource,
|
source: InvocationSource,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if self.editor.diff().is_some() {
|
if self.editor.diff().is_some() && !self.editor.diff_editing() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.editor.buffer_id() == source.buffer_id
|
self.editor.buffer_id() == source.buffer_id
|
||||||
@@ -791,6 +822,7 @@ impl EditorSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.editor.refresh_live_diff();
|
||||||
Ok(changed)
|
Ok(changed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,10 +839,7 @@ 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)
|
if !self.source_is_current(source) || self.editor.buffer_id() != source.buffer_id {
|
||||||
|| self.editor.buffer_id() != source.buffer_id
|
|
||||||
|| self.editor.diff().is_some()
|
|
||||||
{
|
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
if effects
|
if effects
|
||||||
@@ -826,6 +855,9 @@ impl EditorSession {
|
|||||||
}
|
}
|
||||||
return Ok(changed);
|
return Ok(changed);
|
||||||
}
|
}
|
||||||
|
if self.editor.diff().is_some() && !self.editor.diff_editing() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
let changed = !effects.is_empty();
|
let changed = !effects.is_empty();
|
||||||
self.editor.apply(effects)?;
|
self.editor.apply(effects)?;
|
||||||
Ok(changed)
|
Ok(changed)
|
||||||
@@ -1897,24 +1929,8 @@ Vamp.configure do |config|
|
|||||||
end
|
end
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
assert!(
|
|
||||||
session
|
|
||||||
.editor()
|
|
||||||
.message()
|
|
||||||
.contains("unavailable capability: graphics")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
session
|
|
||||||
.editor()
|
|
||||||
.message()
|
|
||||||
.contains("Command already registered: q")
|
|
||||||
);
|
|
||||||
for command in ["draw", "leaked"] {
|
for command in ["draw", "leaked"] {
|
||||||
session.execute(command);
|
session.execute(command);
|
||||||
assert_eq!(
|
|
||||||
session.editor().message(),
|
|
||||||
format!("Unknown command: {command}")
|
|
||||||
);
|
|
||||||
assert_eq!(session.editor().lines(), &[String::new()]);
|
assert_eq!(session.editor().lines(), &[String::new()]);
|
||||||
}
|
}
|
||||||
session.handle(Input::Char('g'));
|
session.handle(Input::Char('g'));
|
||||||
@@ -3314,11 +3330,131 @@ end
|
|||||||
});
|
});
|
||||||
assert_eq!(session.editor().context().text, "original");
|
assert_eq!(session.editor().context().text, "original");
|
||||||
assert_eq!(fs::read_to_string(&source).unwrap(), "original");
|
assert_eq!(fs::read_to_string(&source).unwrap(), "original");
|
||||||
session.handle(Input::Escape);
|
session.execute("diff.close");
|
||||||
session.execute("ping");
|
session.execute("ping");
|
||||||
drain_until(&mut session, |session| {
|
drain_until(&mut session, |session| {
|
||||||
session.editor().message() == "still alive"
|
session.editor().message() == "still alive"
|
||||||
});
|
});
|
||||||
assert_eq!(session.editor().context().text, "original");
|
assert_eq!(session.editor().context().text, "original");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn async_responses_edit_only_current_bound_working_after_and_refresh_projection() {
|
||||||
|
use crate::diff::{
|
||||||
|
Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision,
|
||||||
|
RevisionTarget,
|
||||||
|
};
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
fixture.file("buffer.txt", "base");
|
||||||
|
let mut session = fixture.open("");
|
||||||
|
session.editor.execute("diff");
|
||||||
|
let (id, _) = session.editor.take_diff_request().unwrap();
|
||||||
|
let revision = |target, label: &str| Revision {
|
||||||
|
target,
|
||||||
|
label: label.into(),
|
||||||
|
description: String::new(),
|
||||||
|
category: Category::Commits,
|
||||||
|
};
|
||||||
|
session.editor.apply_diff_result(
|
||||||
|
id,
|
||||||
|
Ok(DiffOutcome::Catalog(Catalog {
|
||||||
|
kind: RepoKind::Git,
|
||||||
|
notice: None,
|
||||||
|
entries: vec![
|
||||||
|
revision(RevisionTarget::Commit("base".into()), "base"),
|
||||||
|
revision(RevisionTarget::Working, "Working"),
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
for input in [Input::Down, Input::Enter, Input::Enter] {
|
||||||
|
session.editor.handle(input);
|
||||||
|
}
|
||||||
|
let (id, _) = session.editor.take_diff_request().unwrap();
|
||||||
|
session.editor.apply_diff_result(
|
||||||
|
id,
|
||||||
|
Ok(DiffOutcome::Comparison(Comparison {
|
||||||
|
root: fixture.0.canonicalize().unwrap(),
|
||||||
|
before: "base".into(),
|
||||||
|
after: "Working".into(),
|
||||||
|
editable_paths: [PathBuf::from("buffer.txt")].into_iter().collect(),
|
||||||
|
files: vec![ChangedFile {
|
||||||
|
path: PathBuf::from("buffer.txt"),
|
||||||
|
old_path: None,
|
||||||
|
status: FileStatus::Modified,
|
||||||
|
before: vec!["base".into()],
|
||||||
|
after: vec!["base".into()],
|
||||||
|
before_eof: false,
|
||||||
|
after_eof: false,
|
||||||
|
hunks: Vec::new(),
|
||||||
|
notice: None,
|
||||||
|
}],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
session.editor.handle(Input::BackTab);
|
||||||
|
let source = InvocationSource {
|
||||||
|
buffer_id: session.editor.buffer_id(),
|
||||||
|
revision: session.editor.text_revision(),
|
||||||
|
};
|
||||||
|
session
|
||||||
|
.requests
|
||||||
|
.entry("fixture".into())
|
||||||
|
.or_default()
|
||||||
|
.pending
|
||||||
|
.insert(1, source);
|
||||||
|
assert!(
|
||||||
|
session
|
||||||
|
.apply_response("fixture", 1, vec![Effect::Insert("accepted".into())])
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(session.editor.lines(), ["acceptedbase"]);
|
||||||
|
let diff = session.editor.diff().unwrap();
|
||||||
|
assert_eq!(diff.live_revision, Some(session.editor.text_revision()));
|
||||||
|
assert_eq!(
|
||||||
|
diff.comparison.as_ref().unwrap().files[0].after,
|
||||||
|
session.editor.lines()
|
||||||
|
);
|
||||||
|
|
||||||
|
session
|
||||||
|
.requests
|
||||||
|
.get_mut("fixture")
|
||||||
|
.unwrap()
|
||||||
|
.pending
|
||||||
|
.insert(2, source);
|
||||||
|
assert!(
|
||||||
|
!session
|
||||||
|
.apply_response("fixture", 2, vec![Effect::Insert("stale".into())])
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
let current = InvocationSource {
|
||||||
|
buffer_id: session.editor.buffer_id(),
|
||||||
|
revision: session.editor.text_revision(),
|
||||||
|
};
|
||||||
|
session.editor.scroll_diff(Focus::Before, false);
|
||||||
|
session
|
||||||
|
.requests
|
||||||
|
.get_mut("fixture")
|
||||||
|
.unwrap()
|
||||||
|
.pending
|
||||||
|
.insert(3, current);
|
||||||
|
assert!(
|
||||||
|
!session
|
||||||
|
.apply_response("fixture", 3, vec![Effect::Insert("readonly".into())])
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(session.editor.lines(), ["acceptedbase"]);
|
||||||
|
session
|
||||||
|
.requests
|
||||||
|
.get_mut("fixture")
|
||||||
|
.unwrap()
|
||||||
|
.pending
|
||||||
|
.insert(4, current);
|
||||||
|
assert!(
|
||||||
|
session
|
||||||
|
.apply_response("fixture", 4, vec![Effect::Message("allowed".into())])
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(session.editor.message(), "allowed");
|
||||||
|
session.editor.execute("diff.close");
|
||||||
|
assert!(session.editor.dirty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+789
-269
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
|||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
path::PathBuf,
|
||||||
|
sync::atomic::{AtomicU64, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
|
use vamp::editor::{Editor, Feature, Input};
|
||||||
|
|
||||||
|
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
struct Fixture(PathBuf);
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn new() -> Self {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"vamp-commands-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
NEXT.fetch_add(1, Ordering::Relaxed),
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&root).unwrap();
|
||||||
|
Self(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn editor(&self, name: &str, text: &str) -> Editor {
|
||||||
|
let path = self.0.join(name);
|
||||||
|
fs::write(&path, text).unwrap();
|
||||||
|
Editor::open(Some(path), vec!["text".into()]).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert(editor: &mut Editor, text: &str) {
|
||||||
|
for input in [Input::Char('i'), Input::Paste(text.into()), Input::Escape] {
|
||||||
|
editor.handle(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_and_w_preserve_spaced_path_arguments_and_write_current_contents() {
|
||||||
|
for command in ["save", "w"] {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut editor = fixture.editor("original file.txt", "original\n");
|
||||||
|
insert(&mut editor, "changed ");
|
||||||
|
let destination = fixture.0.join("saved file with spaces.txt");
|
||||||
|
|
||||||
|
editor.execute(&format!(":{command} {}", destination.display()));
|
||||||
|
|
||||||
|
assert_eq!(editor.path(), Some(destination.as_path()), "{command}");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(&destination).unwrap(),
|
||||||
|
"changed original\n"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(fixture.0.join("original file.txt")).unwrap(),
|
||||||
|
"original\n"
|
||||||
|
);
|
||||||
|
assert!(!editor.dirty(), "{command}");
|
||||||
|
assert!(!editor.should_quit(), "{command}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quit_and_q_refuse_dirty_buffers_then_exit_when_clean() {
|
||||||
|
for command in ["quit", "q"] {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut editor = fixture.editor("file.txt", "original\n");
|
||||||
|
insert(&mut editor, "changed ");
|
||||||
|
|
||||||
|
editor.execute(command);
|
||||||
|
|
||||||
|
assert!(!editor.should_quit(), "{command}");
|
||||||
|
assert!(editor.dirty(), "{command}");
|
||||||
|
assert_eq!(editor.context().text, "changed original\n");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(fixture.0.join("file.txt")).unwrap(),
|
||||||
|
"original\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
editor.handle(Input::Char('u'));
|
||||||
|
assert!(!editor.dirty(), "{command}");
|
||||||
|
editor.execute(command);
|
||||||
|
assert!(editor.should_quit(), "{command}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn force_quit_and_q_bang_validate_arguments_before_discarding_changes() {
|
||||||
|
for command in ["force_quit", "q!"] {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut editor = fixture.editor("file.txt", "original\n");
|
||||||
|
insert(&mut editor, "changed ");
|
||||||
|
|
||||||
|
editor.execute(&format!("{command} unexpected argument"));
|
||||||
|
|
||||||
|
assert!(!editor.should_quit(), "{command}");
|
||||||
|
assert!(editor.dirty(), "{command}");
|
||||||
|
assert_eq!(editor.context().text, "changed original\n");
|
||||||
|
|
||||||
|
editor.execute(command);
|
||||||
|
assert!(editor.should_quit(), "{command}");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(fixture.0.join("file.txt")).unwrap(),
|
||||||
|
"original\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn buffer_next_and_bnext_preserve_each_buffers_dirty_text_and_undo() {
|
||||||
|
for command in ["buffer_next", "bnext"] {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut editor = fixture.editor("first.txt", "first\n");
|
||||||
|
let first_id = editor.buffer_id();
|
||||||
|
insert(&mut editor, "changed ");
|
||||||
|
let second_path = fixture.0.join("second file.txt");
|
||||||
|
fs::write(&second_path, "second\n").unwrap();
|
||||||
|
editor.execute(&format!("edit {}", second_path.display()));
|
||||||
|
let second_id = editor.buffer_id();
|
||||||
|
assert_ne!(first_id, second_id);
|
||||||
|
insert(&mut editor, "edited ");
|
||||||
|
|
||||||
|
editor.execute(command);
|
||||||
|
|
||||||
|
assert_eq!(editor.buffer_id(), first_id, "{command}");
|
||||||
|
assert_eq!(editor.context().text, "changed first\n");
|
||||||
|
assert!(editor.dirty(), "{command}");
|
||||||
|
editor.handle(Input::Char('u'));
|
||||||
|
assert_eq!(editor.context().text, "first\n");
|
||||||
|
assert!(!editor.dirty(), "{command}");
|
||||||
|
|
||||||
|
editor.execute(command);
|
||||||
|
|
||||||
|
assert_eq!(editor.buffer_id(), second_id, "{command}");
|
||||||
|
assert_eq!(editor.context().text, "edited second\n");
|
||||||
|
assert!(editor.dirty(), "{command}");
|
||||||
|
editor.handle(Input::Char('u'));
|
||||||
|
assert_eq!(editor.context().text, "second\n");
|
||||||
|
assert!(!editor.dirty(), "{command}");
|
||||||
|
assert_eq!(editor.buffer_count(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(fixture.0.join("first.txt")).unwrap(),
|
||||||
|
"first\n"
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read_to_string(&second_path).unwrap(), "second\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtin_canonical_and_alias_collisions_reject_the_entire_feature() {
|
||||||
|
for reserved in ["save", "w", "quit", "q"] {
|
||||||
|
let mut editor = Editor::open(None, vec!["text".into()]).unwrap();
|
||||||
|
let feature = |commands: Vec<String>| Feature {
|
||||||
|
name: "example".into(),
|
||||||
|
required_capabilities: vec!["text".into()],
|
||||||
|
commands,
|
||||||
|
core: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
editor
|
||||||
|
.register(feature(vec!["example.run".into(), reserved.into()]))
|
||||||
|
.is_err(),
|
||||||
|
"{reserved}"
|
||||||
|
);
|
||||||
|
assert!(editor.execute("example.run").is_none(), "{reserved}");
|
||||||
|
|
||||||
|
editor
|
||||||
|
.register(feature(vec!["example.run".into()]))
|
||||||
|
.unwrap();
|
||||||
|
let invocation = editor.execute("example.run remaining args").unwrap();
|
||||||
|
assert_eq!(invocation.plugin, "example");
|
||||||
|
}
|
||||||
|
}
|
||||||
+173
-16
@@ -76,7 +76,7 @@ fn type_query(session: &mut EditorSession, text: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn typed_diff_compares_disk_not_unsaved_buffers_and_preserves_undo() {
|
fn typed_diff_edits_unsaved_working_after_and_preserves_undo_on_explicit_close() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
fixture.git(&["init", "-q"]);
|
fixture.git(&["init", "-q"]);
|
||||||
let source = fixture.0.join("repo/buffer.rs");
|
let source = fixture.0.join("repo/buffer.rs");
|
||||||
@@ -128,7 +128,12 @@ fn typed_diff_compares_disk_not_unsaved_buffers_and_preserves_undo() {
|
|||||||
let file = &comparison.files[0];
|
let file = &comparison.files[0];
|
||||||
assert_eq!(file.path, Path::new("buffer.rs"));
|
assert_eq!(file.path, Path::new("buffer.rs"));
|
||||||
assert_eq!(file.before, ["before"]);
|
assert_eq!(file.before, ["before"]);
|
||||||
assert_eq!(file.after, ["after"]);
|
assert_eq!(file.after, ["uafter", ""]);
|
||||||
|
session.handle(Input::Char('\t'));
|
||||||
|
assert_eq!(
|
||||||
|
session.editor().diff().unwrap().focus,
|
||||||
|
vamp::diff::Focus::Before
|
||||||
|
);
|
||||||
for input in [
|
for input in [
|
||||||
Input::Char('i'),
|
Input::Char('i'),
|
||||||
Input::Char('x'),
|
Input::Char('x'),
|
||||||
@@ -137,29 +142,181 @@ fn typed_diff_compares_disk_not_unsaved_buffers_and_preserves_undo() {
|
|||||||
session.handle(input);
|
session.handle(input);
|
||||||
}
|
}
|
||||||
session.handle(Input::Escape);
|
session.handle(Input::Escape);
|
||||||
assert!(
|
assert!(session.editor().diff().is_some());
|
||||||
!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_eq!(session.editor().context().text, "uafter\n");
|
||||||
assert!(session.editor().dirty());
|
|
||||||
assert_eq!(session.editor().cursor(), cursor);
|
assert_eq!(session.editor().cursor(), cursor);
|
||||||
assert_eq!(fs::read_to_string(&source).unwrap(), "after\n");
|
assert_eq!(fs::read_to_string(&source).unwrap(), "after\n");
|
||||||
|
|
||||||
|
session.handle(Input::Char('\t'));
|
||||||
|
assert_eq!(
|
||||||
|
session.editor().diff().unwrap().focus,
|
||||||
|
vamp::diff::Focus::After
|
||||||
|
);
|
||||||
|
for input in [Input::Char('i'), Input::Char('x'), Input::Escape] {
|
||||||
|
session.handle(input);
|
||||||
|
}
|
||||||
|
assert_eq!(session.editor().context().text, "xuafter\n");
|
||||||
|
assert!(session.editor().diff().is_some());
|
||||||
|
session.handle(Input::Char('u'));
|
||||||
|
assert_eq!(session.editor().context().text, "uafter\n");
|
||||||
|
session.handle(Input::Char('U'));
|
||||||
|
assert_eq!(session.editor().context().text, "xuafter\n");
|
||||||
|
session.execute("w");
|
||||||
|
assert_eq!(fs::read_to_string(&source).unwrap(), "xuafter\n");
|
||||||
|
assert!(!session.editor().dirty());
|
||||||
|
session.handle(Input::Char('u'));
|
||||||
|
assert_eq!(session.editor().context().text, "uafter\n");
|
||||||
|
assert!(session.editor().dirty());
|
||||||
|
|
||||||
|
session.execute("diff.close");
|
||||||
|
assert!(session.editor().diff().is_none());
|
||||||
|
assert_eq!(session.editor().context().text, "uafter\n");
|
||||||
session.handle(Input::Char('u'));
|
session.handle(Input::Char('u'));
|
||||||
assert_eq!(session.editor().context().text, "after\n");
|
assert_eq!(session.editor().context().text, "after\n");
|
||||||
|
assert_eq!(fs::read_to_string(&source).unwrap(), "xuafter\n");
|
||||||
session.execute(":diff");
|
session.execute(":diff");
|
||||||
assert!(session.editor().diff().is_some());
|
assert!(session.editor().diff().is_some());
|
||||||
session.handle(Input::Escape);
|
session.execute("diff.close");
|
||||||
session.poll();
|
session.poll();
|
||||||
assert!(session.editor().diff().is_none());
|
assert!(session.editor().diff().is_none());
|
||||||
assert_eq!(fixture.git(&["rev-parse", "HEAD"]), base);
|
assert_eq!(fixture.git(&["rev-parse", "HEAD"]), base);
|
||||||
assert_eq!(fixture.git(&["status", "--porcelain"]), "M buffer.rs");
|
assert_eq!(fixture.git(&["status", "--porcelain"]), "M buffer.rs");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editable_diff_navigation_skips_omitted_lines_without_expanding_excerpts() {
|
||||||
|
use vamp::diff::DisplayRow;
|
||||||
|
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
fixture.git(&["init", "-q"]);
|
||||||
|
let source = fixture.0.join("repo/context.txt");
|
||||||
|
let mut lines = (1..=100)
|
||||||
|
.map(|line| format!("line_{line:03} words"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
lines[49].clear(); // Hidden short lines must not clamp the visible caret column.
|
||||||
|
fs::write(&source, format!("{}\n", lines.join("\n"))).unwrap();
|
||||||
|
fixture.git(&["add", "context.txt"]);
|
||||||
|
fixture.git(&["commit", "-qm", "base"]);
|
||||||
|
let base = fixture.git(&["rev-parse", "HEAD"]);
|
||||||
|
lines[19] = "changed_020 words".into();
|
||||||
|
lines[79] = "changed_080 words".into();
|
||||||
|
let text = format!("{}\n", lines.join("\n"));
|
||||||
|
fs::write(&source, &text).unwrap();
|
||||||
|
let config = fixture.0.join("config.rb");
|
||||||
|
fs::write(&config, "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.diff_context_lines = 1\n config.diff_expand_lines = 2\n config.bind 'ctrl+f', config.core.down\n config.bind 'ctrl+b', config.core.up\nend\n").unwrap();
|
||||||
|
let mut session =
|
||||||
|
EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap();
|
||||||
|
session.execute("diff");
|
||||||
|
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);
|
||||||
|
session.handle(Input::Enter);
|
||||||
|
wait_until(&mut session, |session| {
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.diff()
|
||||||
|
.is_some_and(|diff| diff.phase == Phase::View)
|
||||||
|
});
|
||||||
|
session.handle(Input::BackTab);
|
||||||
|
let shown = |session: &EditorSession| {
|
||||||
|
let diff = session.editor().diff().unwrap();
|
||||||
|
diff.display_rows
|
||||||
|
.iter()
|
||||||
|
.filter_map(|row| match *row {
|
||||||
|
DisplayRow::Line(index) => diff.rows[index].after,
|
||||||
|
DisplayRow::Fold { .. } => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
let excerpts = vec![18, 19, 20, 78, 79, 80];
|
||||||
|
assert_eq!(shown(&session), excerpts);
|
||||||
|
assert_eq!(session.editor().cursor().row, 18);
|
||||||
|
type_query(&mut session, "5l");
|
||||||
|
for (keys, row) in [("2j", 20), ("j", 78), ("k", 20), ("3j", 80), ("5k", 18)] {
|
||||||
|
type_query(&mut session, keys);
|
||||||
|
assert_eq!(session.editor().cursor().row, row, "{keys}");
|
||||||
|
assert_eq!(session.editor().cursor().column, 5, "{keys}");
|
||||||
|
assert_eq!(shown(&session), excerpts, "{keys}");
|
||||||
|
}
|
||||||
|
for (keys, row) in [
|
||||||
|
("gb", 80),
|
||||||
|
("gt", 18),
|
||||||
|
("2j$w", 78),
|
||||||
|
("b", 20),
|
||||||
|
("$2w", 78),
|
||||||
|
("0B", 20),
|
||||||
|
] {
|
||||||
|
type_query(&mut session, keys);
|
||||||
|
assert_eq!(session.editor().cursor().row, row, "{keys}");
|
||||||
|
assert_eq!(shown(&session), excerpts, "{keys}");
|
||||||
|
}
|
||||||
|
session.handle(Input::Ctrl('f'));
|
||||||
|
assert_eq!(session.editor().cursor().row, 78);
|
||||||
|
session.handle(Input::Ctrl('b'));
|
||||||
|
assert_eq!(session.editor().cursor().row, 20);
|
||||||
|
type_query(&mut session, "Vj");
|
||||||
|
assert_eq!(session.editor().cursor().row, 78);
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
session.handle(Input::Ctrl('k'));
|
||||||
|
assert_eq!(
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.carets()
|
||||||
|
.iter()
|
||||||
|
.map(|caret| caret.row)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[20, 78]
|
||||||
|
);
|
||||||
|
type_query(&mut session, "j");
|
||||||
|
assert_eq!(
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.carets()
|
||||||
|
.iter()
|
||||||
|
.map(|caret| caret.row)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[78, 79]
|
||||||
|
);
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
type_query(&mut session, "gt2j$v");
|
||||||
|
session.handle(Input::Ctrl('a'));
|
||||||
|
assert_eq!(
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.carets()
|
||||||
|
.iter()
|
||||||
|
.map(|caret| caret.row)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
excerpts
|
||||||
|
);
|
||||||
|
assert_eq!(shown(&session), excerpts);
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
type_query(&mut session, "/line_040");
|
||||||
|
session.handle(Input::Enter);
|
||||||
|
assert_eq!(session.editor().cursor().row, 20);
|
||||||
|
type_query(&mut session, "/line_079");
|
||||||
|
session.handle(Input::Enter);
|
||||||
|
assert_eq!(session.editor().cursor().row, 78);
|
||||||
|
assert_eq!(shown(&session), excerpts);
|
||||||
|
type_query(&mut session, "gt2ji");
|
||||||
|
session.handle(Input::Down);
|
||||||
|
assert_eq!(session.editor().cursor().row, 78);
|
||||||
|
session.handle(Input::Up);
|
||||||
|
assert_eq!(session.editor().cursor().row, 20);
|
||||||
|
session.handle(Input::Escape);
|
||||||
|
assert_eq!(shown(&session), excerpts);
|
||||||
|
assert_eq!(session.editor().context().text, text);
|
||||||
|
assert!(!session.editor().dirty());
|
||||||
|
assert_eq!(fs::read_to_string(&source).unwrap(), text);
|
||||||
|
type_query(&mut session, "[");
|
||||||
|
assert_eq!(shown(&session), [16, 17, 18, 19, 20, 78, 79, 80]);
|
||||||
|
type_query(&mut session, "]");
|
||||||
|
assert_eq!(shown(&session), [16, 17, 18, 19, 20, 21, 22, 78, 79, 80]);
|
||||||
|
}
|
||||||
|
|||||||
+123
-15
@@ -237,9 +237,15 @@ fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_
|
|||||||
&& r.category == Category::Tags
|
&& r.category == Category::Tags
|
||||||
&& r.target == RevisionTarget::Commit(id.clone())));
|
&& r.target == RevisionTarget::Commit(id.clone())));
|
||||||
let comparison = repository
|
let comparison = repository
|
||||||
.compare(&revision(&id, RepoKind::Git), &working(RepoKind::Git))
|
.compare(&revision(&id, RepoKind::Git), &working(RepoKind::Git), &[])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let text = changed(&comparison, Path::new("text"));
|
let text = changed(&comparison, Path::new("text"));
|
||||||
|
assert_eq!(comparison.root, fs::canonicalize(&root).unwrap());
|
||||||
|
assert!(comparison.editable_paths.contains(Path::new("text")));
|
||||||
|
assert!(comparison.editable_paths.contains(Path::new("empty-added")));
|
||||||
|
for path in ["binary", "link", "removed", "type-switch"] {
|
||||||
|
assert!(!comparison.editable_paths.contains(Path::new(path)));
|
||||||
|
}
|
||||||
assert_eq!(text.after, ["one", "two", "three"]);
|
assert_eq!(text.after, ["one", "two", "three"]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(
|
(
|
||||||
@@ -338,15 +344,16 @@ fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_
|
|||||||
assert_eq!(moved.before, moved.after);
|
assert_eq!(moved.before, moved.after);
|
||||||
assert!(moved.hunks.is_empty());
|
assert!(moved.hunks.is_empty());
|
||||||
let reverse = repository
|
let reverse = repository
|
||||||
.compare(&working(RepoKind::Git), &revision(&id, RepoKind::Git))
|
.compare(&working(RepoKind::Git), &revision(&id, RepoKind::Git), &[])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
assert!(reverse.editable_paths.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
changed(&reverse, Path::new("added")).status,
|
changed(&reverse, Path::new("added")).status,
|
||||||
FileStatus::Removed
|
FileStatus::Removed
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
repository
|
repository
|
||||||
.compare(&working(RepoKind::Git), &working(RepoKind::Git))
|
.compare(&working(RepoKind::Git), &working(RepoKind::Git), &[])
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.files
|
.files
|
||||||
.is_empty()
|
.is_empty()
|
||||||
@@ -433,17 +440,22 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st
|
|||||||
.success()
|
.success()
|
||||||
);
|
);
|
||||||
let comparison = repository
|
let comparison = repository
|
||||||
.compare(&revision(&local, RepoKind::Git), &remote)
|
.compare(
|
||||||
|
&revision(&local, RepoKind::Git),
|
||||||
|
&remote,
|
||||||
|
&[(active.join("file"), "unsaved local text\n".into())],
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
assert!(comparison.editable_paths.is_empty());
|
||||||
assert_eq!(changed(&comparison, Path::new("file")).before, ["local"]);
|
assert_eq!(changed(&comparison, Path::new("file")).before, ["local"]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
changed(&comparison, Path::new("file")).after,
|
changed(&comparison, Path::new("file")).after,
|
||||||
["remote tip"]
|
["remote tip"]
|
||||||
);
|
);
|
||||||
let comparison = repository.compare(&tag, &remote).unwrap();
|
let comparison = repository.compare(&tag, &remote, &[]).unwrap();
|
||||||
assert_eq!(changed(&comparison, Path::new("file")).before, ["ancestor"]);
|
assert_eq!(changed(&comparison, Path::new("file")).before, ["ancestor"]);
|
||||||
let comparison = repository
|
let comparison = repository
|
||||||
.compare(&remote, &working(RepoKind::Git))
|
.compare(&remote, &working(RepoKind::Git), &[])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(changed(&comparison, Path::new("file")).after, ["local"]);
|
assert_eq!(changed(&comparison, Path::new("file")).after, ["local"]);
|
||||||
let loaded = repository.fetch_remote("origin").unwrap();
|
let loaded = repository.fetch_remote("origin").unwrap();
|
||||||
@@ -457,7 +469,7 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st
|
|||||||
assert_eq!(historic.label, ancestor);
|
assert_eq!(historic.label, ancestor);
|
||||||
assert_eq!(historic.description, "remote ancestor needle");
|
assert_eq!(historic.description, "remote ancestor needle");
|
||||||
let comparison = repository
|
let comparison = repository
|
||||||
.compare(historic, &revision(&local, RepoKind::Git))
|
.compare(historic, &revision(&local, RepoKind::Git), &[])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(changed(&comparison, Path::new("file")).before, ["ancestor"]);
|
assert_eq!(changed(&comparison, Path::new("file")).before, ["ancestor"]);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -530,7 +542,7 @@ fn jj_catalog_and_working_comparisons_prefer_jj_and_never_snapshot_or_merge_oper
|
|||||||
.any(|r| r.label == "baseline" && r.category == Category::Bookmarks)
|
.any(|r| r.label == "baseline" && r.category == Category::Bookmarks)
|
||||||
);
|
);
|
||||||
let comparison = repository
|
let comparison = repository
|
||||||
.compare(baseline, &working(RepoKind::Jujutsu))
|
.compare(baseline, &working(RepoKind::Jujutsu), &[])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(changed(&comparison, Path::new("file")).after, ["disk"]);
|
assert_eq!(changed(&comparison, Path::new("file")).after, ["disk"]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -652,7 +664,7 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
|||||||
.any(|r| r.target == RevisionTarget::Commit(ancestor.clone()))
|
.any(|r| r.target == RevisionTarget::Commit(ancestor.clone()))
|
||||||
);
|
);
|
||||||
let comparison = repository
|
let comparison = repository
|
||||||
.compare(&remote, &working(RepoKind::Jujutsu))
|
.compare(&remote, &working(RepoKind::Jujutsu), &[])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
changed(&comparison, Path::new("file")).before,
|
changed(&comparison, Path::new("file")).before,
|
||||||
@@ -670,7 +682,7 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(historic.label.contains(&ancestor) && historic.label.contains(&change));
|
assert!(historic.label.contains(&ancestor) && historic.label.contains(&change));
|
||||||
assert_eq!(historic.description, "remote historic needle");
|
assert_eq!(historic.description, "remote historic needle");
|
||||||
let comparison = repository.compare(historic, &remote).unwrap();
|
let comparison = repository.compare(historic, &remote, &[]).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
changed(&comparison, Path::new("file")).before,
|
changed(&comparison, Path::new("file")).before,
|
||||||
["remote ancestor"]
|
["remote ancestor"]
|
||||||
@@ -733,7 +745,11 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
|
|||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
repository
|
repository
|
||||||
.compare(&revision("--bad", RepoKind::Git), &working(RepoKind::Git))
|
.compare(
|
||||||
|
&revision("--bad", RepoKind::Git),
|
||||||
|
&working(RepoKind::Git),
|
||||||
|
&[]
|
||||||
|
)
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.contains("Invalid immutable commit")
|
.contains("Invalid immutable commit")
|
||||||
);
|
);
|
||||||
@@ -741,7 +757,8 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
|
|||||||
repository
|
repository
|
||||||
.compare(
|
.compare(
|
||||||
&revision(&"f".repeat(40), RepoKind::Git),
|
&revision(&"f".repeat(40), RepoKind::Git),
|
||||||
&working(RepoKind::Git)
|
&working(RepoKind::Git),
|
||||||
|
&[],
|
||||||
)
|
)
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.contains("unavailable")
|
.contains("unavailable")
|
||||||
@@ -752,7 +769,7 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
|
|||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
repository
|
repository
|
||||||
.compare(&loader, &revision(&id, RepoKind::Git))
|
.compare(&loader, &revision(&id, RepoKind::Git), &[])
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.contains("history loader")
|
.contains("history loader")
|
||||||
);
|
);
|
||||||
@@ -792,7 +809,11 @@ fn unchanged_submodules_are_not_false_changes_and_changed_pointers_are_explicit(
|
|||||||
let mut repository = open(&root);
|
let mut repository = open(&root);
|
||||||
assert!(
|
assert!(
|
||||||
repository
|
repository
|
||||||
.compare(&revision(&baseline, RepoKind::Git), &working(RepoKind::Git))
|
.compare(
|
||||||
|
&revision(&baseline, RepoKind::Git),
|
||||||
|
&working(RepoKind::Git),
|
||||||
|
&[]
|
||||||
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.files
|
.files
|
||||||
.is_empty()
|
.is_empty()
|
||||||
@@ -804,7 +825,11 @@ fn unchanged_submodules_are_not_false_changes_and_changed_pointers_are_explicit(
|
|||||||
git(&root.join("nested"), &["checkout", "--detach", &second]);
|
git(&root.join("nested"), &["checkout", "--detach", &second]);
|
||||||
let state = disk_state(&root);
|
let state = disk_state(&root);
|
||||||
let comparison = repository
|
let comparison = repository
|
||||||
.compare(&revision(&baseline, RepoKind::Git), &working(RepoKind::Git))
|
.compare(
|
||||||
|
&revision(&baseline, RepoKind::Git),
|
||||||
|
&working(RepoKind::Git),
|
||||||
|
&[],
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let submodule = changed(&comparison, Path::new("nested"));
|
let submodule = changed(&comparison, Path::new("nested"));
|
||||||
assert_eq!(submodule.status, FileStatus::Modified);
|
assert_eq!(submodule.status, FileStatus::Modified);
|
||||||
@@ -876,6 +901,7 @@ fn multiple_remotes_retain_tag_only_history_even_when_tag_names_collide() {
|
|||||||
.compare(
|
.compare(
|
||||||
&revision(&ids[0], RepoKind::Git),
|
&revision(&ids[0], RepoKind::Git),
|
||||||
&revision(&ids[1], RepoKind::Git),
|
&revision(&ids[1], RepoKind::Git),
|
||||||
|
&[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(changed(&comparison, Path::new("file")).before, ["one"]);
|
assert_eq!(changed(&comparison, Path::new("file")).before, ["one"]);
|
||||||
@@ -989,6 +1015,7 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable
|
|||||||
.compare(
|
.compare(
|
||||||
&revision(&base, RepoKind::Jujutsu),
|
&revision(&base, RepoKind::Jujutsu),
|
||||||
&revision(&merged, RepoKind::Jujutsu),
|
&revision(&merged, RepoKind::Jujutsu),
|
||||||
|
&[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let conflict = changed(&comparison, Path::new("conflict"));
|
let conflict = changed(&comparison, Path::new("conflict"));
|
||||||
@@ -1009,6 +1036,7 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable
|
|||||||
.compare(
|
.compare(
|
||||||
&revision(&merged, RepoKind::Jujutsu),
|
&revision(&merged, RepoKind::Jujutsu),
|
||||||
&working(RepoKind::Jujutsu),
|
&working(RepoKind::Jujutsu),
|
||||||
|
&[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1020,3 +1048,83 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable
|
|||||||
);
|
);
|
||||||
assert_eq!(disk_state(&root), state);
|
assert_eq!(disk_state(&root), state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsaved_overlays_are_working_after_only_and_cannot_follow_nonregular_paths() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let root = fs::canonicalize(fixture.dir("overlay")).unwrap();
|
||||||
|
init_git(&root);
|
||||||
|
fs::create_dir(root.join("sub")).unwrap();
|
||||||
|
fs::write(root.join("file"), "base\n").unwrap();
|
||||||
|
fs::write(root.join("sub/file"), "nested\n").unwrap();
|
||||||
|
fs::write(root.join("binary"), b"\0binary").unwrap();
|
||||||
|
git(&root, &["add", "."]);
|
||||||
|
git(&root, &["commit", "-m", "base"]);
|
||||||
|
let id = git(&root, &["rev-parse", "HEAD"]);
|
||||||
|
let outside = fixture.dir("outside");
|
||||||
|
fs::write(outside.join("file"), "outside\n").unwrap();
|
||||||
|
symlink(&outside, root.join("linked")).unwrap();
|
||||||
|
symlink("file", root.join("link")).unwrap();
|
||||||
|
let state = disk_state(&root);
|
||||||
|
let buffers = vec![
|
||||||
|
(root.join("file"), "unsaved\n".into()),
|
||||||
|
(root.join("binary"), "not binary\n".into()),
|
||||||
|
(root.join("missing"), "not on disk\n".into()),
|
||||||
|
(root.join("link"), "not a regular file\n".into()),
|
||||||
|
(root.join("linked/file"), "outside via link\n".into()),
|
||||||
|
(outside.join("file"), "outside\n".into()),
|
||||||
|
(root.join("sub/../../outside/file"), "escaped\n".into()),
|
||||||
|
(root.join(".git/config"), "metadata\n".into()),
|
||||||
|
];
|
||||||
|
let mut repository = open(&root.join("sub"));
|
||||||
|
let base = revision(&id, RepoKind::Git);
|
||||||
|
let checkout = working(RepoKind::Git);
|
||||||
|
let comparison = repository.compare(&base, &checkout, &buffers).unwrap();
|
||||||
|
assert_eq!(comparison.root, fs::canonicalize(&root).unwrap());
|
||||||
|
assert_eq!(changed(&comparison, Path::new("file")).after, ["unsaved"]);
|
||||||
|
assert!(comparison.editable_paths.contains(Path::new("file")));
|
||||||
|
for path in [
|
||||||
|
"binary",
|
||||||
|
"missing",
|
||||||
|
"link",
|
||||||
|
"linked",
|
||||||
|
"linked/file",
|
||||||
|
".git/config",
|
||||||
|
] {
|
||||||
|
assert!(!comparison.editable_paths.contains(Path::new(path)));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!comparison
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.any(|file| file.path == Path::new("binary")
|
||||||
|
|| file.path == Path::new("missing")
|
||||||
|
|| file.path == Path::new("linked/file"))
|
||||||
|
);
|
||||||
|
let immutable = repository.compare(&checkout, &base, &buffers).unwrap();
|
||||||
|
assert!(immutable.editable_paths.is_empty());
|
||||||
|
assert!(
|
||||||
|
!immutable
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.any(|file| file.path == Path::new("file"))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
repository
|
||||||
|
.compare(&base, &base, &buffers)
|
||||||
|
.unwrap()
|
||||||
|
.files
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
let same_checkout = repository.compare(&checkout, &checkout, &buffers).unwrap();
|
||||||
|
assert_eq!(changed(&same_checkout, Path::new("file")).before, ["base"]);
|
||||||
|
assert_eq!(
|
||||||
|
changed(&same_checkout, Path::new("file")).after,
|
||||||
|
["unsaved"]
|
||||||
|
);
|
||||||
|
assert_eq!(disk_state(&root), state);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(outside.join("file")).unwrap(),
|
||||||
|
"outside\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user