Compare commits
16 Commits
fafec2a6b0
...
f5ba61624f
| Author | SHA1 | Date | |
|---|---|---|---|
| f5ba61624f | |||
| bfdb7c544f | |||
| b16232aa1d | |||
| 8f87b8d3d1 | |||
| 38fcf86195 | |||
| c912abaf17 | |||
| c4f72a45ba | |||
| e36c28c981 | |||
| 7a3c27c30e | |||
| 5382307fcc | |||
| 002f071b0f | |||
| 9f33104cb2 | |||
| b94739183d | |||
| 8c5d218c11 | |||
| d7fbf9a144 | |||
| 6c254f9b0d |
@@ -35,6 +35,7 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal.
|
||||
| Select/copy/cut | `v` characterwise or `V` whole rows, move to extend, then `y`/`d` in Visual |
|
||||
| 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 |
|
||||
| Buffer picker in Normal | Space then `b l` or `:buffers`; type to search, arrows select, Enter switches, Esc cancels |
|
||||
| Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` |
|
||||
| Repository diff in Normal | `:diff`; choose Before then After, Tab changes category, type to search; Esc closes the selector; Working checkout After is editable; `:diff.close` exits the comparison |
|
||||
| Diff layout in Normal | Space then `v` cycles split/unified; Space then `s` flips left/right vs above/below split |
|
||||
|
||||
@@ -18,11 +18,19 @@
|
||||
- Space v cycles split/unified; configurable startup default, dual line numbers, native side focus/editing, and shared highlighting/wrap/excerpts.
|
||||
- [x] diff view: for split view, change split orientation (left/right vs above/below) - left/right by default.
|
||||
- Space s flips orientation; configurable startup default and shared pane geometry/directional focus.
|
||||
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
|
||||
- [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding.
|
||||
- design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files.
|
||||
- [ ] remove the diff palette polling fallback once a released Herdr fix delivers palette-ready DEC 2031 notifications, including palette-only changes with an unchanged light/dark preference. Related upstream issue: [herdrdev/herdr#4183](https://github.com/herdrdev/herdr/issues/4183).
|
||||
- verify that notification-triggered queries return fresh colors in Ghostty through Herdr before removal; retain startup, focus-regain, and notification-driven palette refresh.
|
||||
- [x] add `C` and `D` for deleting line from current cursor position (and entering insert mode: `C`); add `Y` for copying line from current cursor position; add `r` for replacing character at cursor position; add `s` for deleting character at current cursor position and entering insert mode; add `d<count>w` for deleting whole words; add `d<count>b` for deleting backwards (same as `d<count>w` but backwards).
|
||||
- [x] diff view: make foreground of etched areas (where no lines exist) the same dimmed color as the normal gutter text
|
||||
- [x] copying is pretty slow; optimize performance to reduce latency.
|
||||
- [x] enable margins in top/bottom bar elements; give the bottom bar's far-right position indicator a default leading margin of 1 column and no trailing margin.
|
||||
- [x] better fuzzy search when searching revisions and bookmarks (or the git alternatives) in before and after picker for diff view.
|
||||
- [x] git blame (enabled by default).
|
||||
- [x] when jumping to lsp definitions, that line should be center of the buffer (currently placed at the top of the buffer view)
|
||||
- [x] diff view: add ability to mark files as reviewed. this should just put a check mark left of the file modification indicator (e.g., M, A, D, maybe others)
|
||||
- [x] make `<line>g` go to that line.
|
||||
- [x] diff view: if the after point is selected to be current working changes (i.e. `@`), diff view should update to include this after point accordingly (and automatically) - i.e. it should move the after point. this should only be the case when the new checkout is ahead of the after point selected. should be enabled by default.
|
||||
- descendant-only jj/Git checkout tracking, frozen Before, inactive-tab refresh, and `config.diff_follow_working_copy` opt-out; preserve explorer/layout choices and unsaved buffers.
|
||||
- [x] buffer picker (with search).
|
||||
- `:buffers` / Space b l opens a searchable picker; shared selection bindings, stable IDs, dirty/active marks, cross-tab buffers, and cancellation without switching.
|
||||
- [ ] sandboxed, revision-specific LSP analysis for historical diff states and historical tabs.
|
||||
- current limitation: immutable snapshot buffers still use the live repository as their LSP workspace; cross-file analysis can mix historical text with current disk contents.
|
||||
- lazily materialize the complete selected revision, including unchanged files, manifests, and project configuration; run a separate LSP instance rooted in that snapshot.
|
||||
@@ -31,3 +39,14 @@
|
||||
- deny access to the live checkout, home directory, credentials, and SSH agent; deny network access by default. Additional access or code-execution permissions require explicit approval.
|
||||
- fail closed: if these restrictions cannot be enforced, keep read-only viewing available but do not launch historical LSP analysis; never silently fall back to an unrestricted process.
|
||||
- retain the snapshot and analysis environment while historical views/tabs reference it; stop workers and clean up disposable storage after the final reference closes.
|
||||
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
|
||||
- [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding.
|
||||
- design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files.
|
||||
- [ ] remove the diff palette polling fallback once a released Herdr fix delivers palette-ready DEC 2031 notifications, including palette-only changes with an unchanged light/dark preference. Related upstream issue: [herdrdev/herdr#4183](https://github.com/herdrdev/herdr/issues/4183).
|
||||
- verify that notification-triggered queries return fresh colors in Ghostty through Herdr before removal; retain startup, focus-regain, and notification-driven palette refresh.
|
||||
- [ ] improve buffer picker and align with the rest of the editor ui/design language.
|
||||
- [ ] pull a pr description and display it in a buffer:
|
||||
- open a selector that allows you to pick a pr from remote
|
||||
- selector should be searchable
|
||||
- selected pr description is opened in a separate buffer/tab.
|
||||
- open related pr description directly from diff view (should be able to figure out the relevant pr from the branch involved in the diff - if jj, then from tracked bookmark; if just git, then from branch)
|
||||
|
||||
@@ -11,6 +11,7 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
|
||||
- `editor`: owns shared buffer lifecycle/text/saved state/undo history and independent tab/pane layouts, focused views, modes, selections, searches, explorers, and comparisons; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, key-sequence/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||
- `chrome`: owns validated top/bottom bar anchor groups, segment title templates and padding/edge options, builtin defaults, named-value validation, and typed command/search popup anchors. Both bars accept the same segments and layout options. It has no terminal geometry or render callbacks.
|
||||
- `battery`: reads native charge off the input thread, sampling immediately and every 30 seconds through an interruptible worker. macOS CoreFoundation references are type-checked and owned Copy/Create values released; Linux reads system battery sysfs and Windows reads native power status. A one-slot atomic mailbox retains the latest sample; absent/invalid readings clear cached presentation instead of inventing a percentage. Session starts collection only while an unoverridden battery segment is configured.
|
||||
- `blame`: owns local current-line Git attribution against the supplied in-memory text and a cancellable background worker. Git-backed jj uses its working-copy parent's commit through an isolated temporary Git store; active refs/checkouts remain untouched.
|
||||
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
||||
Virtual file leaves retain their original catalog input index for caller-owned annotations; scanned entries and directories remain unannotated.
|
||||
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
|
||||
@@ -50,6 +51,8 @@ Frontends borrow `top_bar_config()` and `bottom_bar_config()` for validated orde
|
||||
Frontends borrow `popup_config()` for independently configured command/search anchors. The TUI resolves each anchor against the chrome-free workspace using the existing bounded popup size; startup configuration does not alter buffer text, modal input, or notification positioning.
|
||||
Frontends borrow `lsp_label()` / `lsp_names()` for the focused buffer's cached ready-server metadata. Initialization-only worker events trigger status redraws independently of diagnostics; session synchronization clears or replaces names when focus changes or a server retires. `notification_visible()` gates persistent operation/diagnostic popups independently of stored message text, so Escape dismissal neither alters text nor collapses carets. Display-only home abbreviation does not change document identity.
|
||||
|
||||
Current-line blame is enabled by default. Session loads whole-file incremental attribution into one active-buffer cache keyed by buffer/text/save revision, disk stamp, and path; cursor, tab, and pane movements within that source reuse it without copying file text or issuing repository commands. Cold results select the current row when they arrive. Edits, saves, source changes, disk refreshes, focus regain, and disabling invalidate attribution; stale results cannot replace a newer source. Metadata is shared per commit with `Arc`, and same-commit navigation reuses the sanitized label. Rendering uses only spare cells after the current logical line's final visible continuation and hides it when the primary caret is offscreen. Disabling drops the worker; comparisons and immutable buffers never schedule live blame.
|
||||
|
||||
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
|
||||
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
|
||||
Frontends borrow `semantic_tokens()` for sorted, single-line grapheme spans from the active buffer. Immutable `Arc` slices are shared by native LSP and buffer storage; painting does not copy tokens. Text/path changes discard overlays, and native updates validate the whole applicable annotation batch before mutation. The TUI merges semantic styling into its existing glyph pass above syntax foregrounds but below diagnostic/search/selection/caret styling.
|
||||
@@ -91,7 +94,7 @@ Diff highlighting lazily owns two instances of the same syntax cache, keyed by t
|
||||
|
||||
## Clipboard providers
|
||||
|
||||
macOS uses fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`, preserving literal RTF/EPS text rather than treating it as rich clipboard data. Linux supports Wayland with `wl-paste`/`wl-copy`, or X11 with `xclip`, on PATH with the corresponding display environment. Other platforms report unsupported access. Commands have a two-second deadline and bounded output; failures terminate/reap their process group.
|
||||
macOS publishes `public.utf8-plain-text` through the native Pasteboard Manager, checking allocation and OS-status failures before reporting success. Publication is synchronous and avoids per-copy interpreter/process startup; native calls do not have a subprocess deadline. Reads retain fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`. Both paths preserve literal RTF/EPS text rather than treating it as rich clipboard data. Linux supports Wayland with `wl-paste`/`wl-copy`, or X11 with `xclip`, on PATH with the corresponding display environment. Other platforms report unsupported access. Subprocess-backed access has a two-second deadline and bounded output; failures terminate/reap the process group.
|
||||
|
||||
## Native LSP
|
||||
|
||||
|
||||
+29
-8
@@ -13,6 +13,7 @@ Rejected configuration opens a centered floating **Configuration error** window,
|
||||
Vamp.configure do |config|
|
||||
config.leader = "space"
|
||||
config.clipboard_mode = "system"
|
||||
config.git_blame = true # false disables current-line attribution
|
||||
config.file_explorer_side = "right"
|
||||
config.file_explorer_view = "tree"
|
||||
config.file_picker_view = "tree"
|
||||
@@ -20,6 +21,7 @@ Vamp.configure do |config|
|
||||
config.diff_expand_lines = 15
|
||||
config.diff_view_mode = "split" # or "unified"
|
||||
config.diff_split_orientation = "vertical" # or "horizontal"
|
||||
config.diff_follow_working_copy = true
|
||||
config.bind "ctrl+h", config.core.left, mode: config.modes.tree
|
||||
config.bind "ctrl+l", config.core.right, mode: config.modes.tree
|
||||
config.plugin "plugins/greeting.rb"
|
||||
@@ -44,18 +46,23 @@ Plugin paths resolve relative to the config file, not the shell's working direct
|
||||
`config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
|
||||
`config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration.
|
||||
|
||||
`config.git_blame` defaults to `true`; set it to `false` to disable current-line inline Git blame and its background worker. Only literal Ruby booleans are accepted; other values reject the configuration. Toggle it for the running session with `:git_blame.toggle`, its palette action, or a custom binding such as `config.bind "leader b", config.core.git_blame.toggle`. There is no default shortcut.
|
||||
|
||||
`config.diff_context_lines = 10` sets how many unchanged aligned lines appear above and below each diff change; it defaults to `10` and accepts nonnegative integers, including `0` for changes only. `config.diff_expand_lines = 15` sets how many additional lines each directional expansion reveals per keypress; it defaults to `15` and accepts positive integers. Both are bounded by the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems). Floats, strings, booleans, negative or oversized values, and a zero expansion step reject the whole Ruby configuration.
|
||||
|
||||
`config.diff_view_mode` selects the initial view for each new comparison: `"split"` (default) or `"unified"`. `config.diff_split_orientation` selects `"vertical"` (left/right, default) or `"horizontal"` (above/below) for split views. Invalid values reject the configuration. Normal-mode **Space v** cycles split/unified; **Space s** flips split orientation. These controls affect the current comparison, not startup defaults, and retain their choices across file switches and tabs. Orientation changes apply only in split view.
|
||||
|
||||
`config.diff_follow_working_copy` defaults to `true`. A Working-checkout After follows descendant checkouts automatically, including in inactive tabs; `false` disables endpoint following without disabling ordinary live-buffer editing or reload. Only literal booleans are accepted. Historical After endpoints never follow. See [repository diff](editing.md#repository-diff) for ancestry and refresh behavior.
|
||||
|
||||
```ruby
|
||||
config.bind "ctrl+b", config.core.diff.mode.toggle
|
||||
config.bind "leader v", config.core.diff.view.toggle
|
||||
config.bind "leader s", config.core.diff.orientation.toggle
|
||||
```
|
||||
|
||||
### Top and bottom bars
|
||||
|
||||
`config.bar.top` and `config.bar.bottom` each accept ordered `left`, `center`, and `right` arrays of segment names. Both bars accept every builtin and custom segment, with the same anchors, title templates, padding, styled edges, and collision handling. Left and right groups anchor to their respective edges; the center group is centered on the whole row, not on the gap remaining between the other groups. Array order is display order within each group. Omitted groups and segment options retain that bar's defaults; an empty array removes that group.
|
||||
`config.bar.top` and `config.bar.bottom` each accept ordered `left`, `center`, and `right` arrays of segment names. Both bars accept every builtin and custom segment, with the same anchors, title templates, margins, padding, styled edges, and collision handling. Left and right groups anchor to their respective edges; the center group is centered on the whole row, not on the gap remaining between the other groups. Array order is display order within each group. Omitted groups and segment options retain that bar's defaults; an empty array removes that group.
|
||||
|
||||
Use the two setters independently; a combined `config.bar = { ... }` assignment is not supported.
|
||||
|
||||
@@ -67,7 +74,7 @@ Vamp.configure do |config|
|
||||
left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer],
|
||||
center: [], right: [:language, :lsp, :position],
|
||||
mode: { padding: 1, leading_edge: "", trailing_edge: "▓░" },
|
||||
position: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
|
||||
position: { padding: 1, leading_margin: 1, trailing_margin: 0, leading_edge: "░▓", trailing_edge: "" }
|
||||
}
|
||||
config.bar.bottom = {
|
||||
left: [:tabs], center: [], right: [:battery],
|
||||
@@ -90,9 +97,11 @@ Each segment's `title` is a literal template supporting only `{index}`, `{view}`
|
||||
|
||||
`:lsp` displays only ready servers assigned to the focused buffer, in configured stack order joined with ` + `. Disabled, initializing, failed, or unassigned servers do not appear. Names come from the initialization reply's `serverInfo.name`, falling back to the configured executable basename; no arbitrary named label can make an inactive server appear active.
|
||||
|
||||
`margin` adds unstyled space outside both edges of a segment, unlike the styled internal `padding`. `leading_margin` and `trailing_margin` override their respective sides. All accept integers from `0` through `64`. Margins default to zero except the bottom bar's `:position`, which has one leading column and no trailing margin. LSP has no default margins. For example, `config.bar.bottom = { position: { leading_margin: 0 } }` removes position's external spacing without changing its padding. Margins count toward anchor placement and collision handling; empty segments consume no margin, and margins yield along with padding and edges when only clipped content fits.
|
||||
|
||||
`padding` adds spaces inside the segment's styling, on both sides of its title, and accepts integers from `0` through `64` (default `1`). `leading_edge` and `trailing_edge` are separate styled glyph strings, not padding; `""` removes an edge. Tabs default to `"░▓"` / `"▓░"` edges; mode has only the trailing `"▓░"` edge, position and battery only the leading `"░▓"` edge, and other segments have none. `first_leading_edge` optionally overrides the first occurrence's leading glyphs: for tabs this is the actual first tab, not the first tab remaining after clipping; other segments have one occurrence. Tabs default this override to `""`, suppressing the first tab's left fade. Unicode widths are measured in terminal cells. Edge strings must be single-line and contain no control characters. Unknown style keys, invalid templates, and wrong types reject configuration.
|
||||
|
||||
Any other segment name is a custom named value, such as `:build_status`. Set initial strings with `config.labels`; an empty value hides the custom segment, including its title override, padding, and edges. Names match `[A-Za-z_][A-Za-z0-9_]*`. Labels are plain text: control characters are replaced rather than interpreted as terminal escapes. The same named value can appear in either bar.
|
||||
Any other segment name is a custom named value, such as `:build_status`. Set initial strings with `config.labels`; an empty value hides the custom segment, including its title override, margins, padding, and edges. Names match `[A-Za-z_][A-Za-z0-9_]*`. Labels are plain text: control characters are replaced rather than interpreted as terminal escapes. The same named value can appear in either bar.
|
||||
|
||||
`:battery` displays `bat N%` from the OS, with an immediate background reading and refreshes every 30 seconds. macOS uses IOKit, Linux uses system battery sysfs entries, and Windows uses `GetSystemPowerStatus`; no shell commands run. Missing, unsupported, or unreadable batteries hide the segment. macOS/Linux currently report the first present internal/system battery; Windows uses the OS's system percentage.
|
||||
|
||||
@@ -145,7 +154,11 @@ Normal mode binds `ctrl+option+h/l` (`ctrl+alt+h/l`) to previous/next tab. Tab a
|
||||
|
||||
The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visual; `gf` follows Markdown file links in Normal only. Bind `config.core.document_start`, `config.core.document_end`, or `config.core.follow_link` to another key or sequence to add another route. A configured/disabled `g`, a configured sequence starting with `g`, or choosing `g` as the leader overrides the built-in prefix in that binding mode.
|
||||
|
||||
Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `config.core.delete_line` or `config.core.change_line` to another key or sequence for another route. A configured/disabled `d` or `c`, a configured sequence starting with that key, or choosing it as the leader overrides its built-in doubled sequence. These are dedicated line actions, not general operator–motion grammar.
|
||||
With a decimal prefix, `g` instead jumps immediately to that one-based source line: `42g` goes to line 42, clamped at EOF, at first nonblank. Normal moves all carets; Visual extends selections. In diff text panes, explicit line jumps reveal folded source lines rather than counting only displayed excerpts. `g` without a count still begins `gt`, `gb`, or `gf`; a count followed by `g` is complete and does not await a suffix. Rebind the Normal/Visual native action `config.core.go_to_line` to use the numeric prefix with another key. Existing `g` bindings and prefixes override this default route.
|
||||
|
||||
Normal `yy` copies complete rows without moving the carets; `dd` / `cc` delete/change complete rows; `dw` / `db` delete forward/backward word spans. Leading and post-operator counts multiply. Bind `config.core.copy_line`, `config.core.delete_line`, `config.core.change_line`, `config.core.delete_word_forward`, or `config.core.delete_word_backward` to another key or sequence for another route. A configured/disabled operator key, a configured sequence starting with that key, or choosing it as the leader overrides its built-in operator prefix. These are dedicated actions, not general operator–motion grammar.
|
||||
|
||||
Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through `config.core.change_to_line_end`, `config.core.delete_to_line_end`, and `config.core.copy_to_line_end`. Normal `r` waits for a literal replacement character through `config.core.replace_character`; `s` deletes the current grapheme and enters Insert through `config.core.substitute_character`. Rebinding these actions preserves their native counts, Unicode boundaries, undo, and read-only guards.
|
||||
|
||||
`f<char>` / `F<char>` seeks forward/backward on the current line in Normal and Visual, with counts such as `2fx`. Rebind `config.core.seek_forward` / `config.core.seek_backward` to a single key or leader sequence; the action then waits for a literal target character. For example, `config.bind "s", config.core.seek_forward` makes `2sx` seek the second following `x`. Pass `mode: config.modes.visual` to install a Visual route. These native-only actions do not have Ex commands.
|
||||
|
||||
@@ -156,13 +169,15 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con
|
||||
| Normal | `insert`, `append`, `open_below`, `open_above` |
|
||||
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
|
||||
| Normal | `join_lines`, `undo`, `redo`, `command` |
|
||||
| Normal | `delete_line`, `change_line` |
|
||||
| Normal | `copy_line`, `delete_line`, `change_line` |
|
||||
| Normal | `change_to_line_end`, `delete_to_line_end`, `copy_to_line_end`, `replace_character`, `substitute_character`, `delete_word_forward`, `delete_word_backward` |
|
||||
| Normal | `save`, `force_save`, `quit`, `force_quit`, `restart`, `force_restart`, `save_and_quit`, `force_save_and_quit`, `plugins` |
|
||||
| Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` |
|
||||
| Normal | `follow_link` |
|
||||
| Normal | `diff`, `diff.view.toggle`, `diff.orientation.toggle` |
|
||||
| Normal | `diff`, `diff.mode.toggle`, `diff.view.toggle`, `diff.orientation.toggle`, `diff.review.toggle` |
|
||||
| Normal, Insert, Visual, Command, Search | `diff.close` |
|
||||
| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` |
|
||||
| Normal, Insert, Visual, Command, Search | `git_blame.toggle` |
|
||||
| Normal | `pane.split_horizontal`, `pane.split_vertical`, `pane.focus_left`, `pane.focus_down`, `pane.focus_up`, `pane.focus_right`, `pane.close`, `pane.force_close` |
|
||||
| Normal | `tab.new`, `tab.previous`, `tab.next`, `tab.close`, `tab.force_close` |
|
||||
| Normal | `jump_back`, `jump_forward` |
|
||||
@@ -173,7 +188,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con
|
||||
| Normal, Visual | `select`, `select_linewise`, `search_next`, `search_previous` |
|
||||
| Normal, Visual | `repeat_last_edit` |
|
||||
| Normal, Visual | `file_picker`, `file_explorer` |
|
||||
| Normal, Visual | `document_start`, `document_end` |
|
||||
| Normal, Visual | `document_start`, `document_end`, `go_to_line` |
|
||||
| Normal, Visual | `seek_forward`, `seek_backward` |
|
||||
| Normal, Insert, Visual | `line_start`, `line_end` |
|
||||
| Normal, Insert, Visual, Tree | `left`, `right` |
|
||||
@@ -195,6 +210,8 @@ Live clean buffers reload external changes automatically, including inactive tab
|
||||
|
||||
`core.quit` refuses unsaved changes in any buffer. `core.edit` and `core.buffer` open prefilled Ex prompts for their required path or buffer ID; deletion actions target the current buffer. Configured `core.force_quit`, `core.buffer_force_delete`, `core.pane.force_close`, and `core.tab.force_close` keys act immediately, without confirmation.
|
||||
|
||||
`config.core.buffers` opens the searchable buffer picker in Normal mode, also available as `:buffers` and default **Space b l**. Its navigation uses `config.modes.selection`, like the command palette. For example, `config.bind "leader b b", config.core.buffers` adds another opening shortcut. Search, acceptance, and cancellation are described under [Buffers](editing.md#buffers).
|
||||
|
||||
`core.restart` and `core.force_restart` have no default keybindings. Their no-argument commands are `:restart` and `:force_restart`, with `:restart!` as an alias for force restart. Normal restart closes clean buffers across every tab, pane, and historical view; any dirty buffer blocks the relaunch with a warning, retains its text and undo/redo history, and leaves the editor interactive without restoring the clean buffers already closed. Force restart discards unsaved text without writing it. The palette confirms force restart, while a configured `config.core.force_restart` binding executes directly.
|
||||
|
||||
Restart relaunches the same executable with the original CLI arguments after full session teardown. Raw mode and input reporting briefly reset, but the alternate screen remains active across the process handoff; the new process resumes input modes and clears and repaints the screen atomically in its first frame. It rereads configuration and the original startup file (or opens an unnamed buffer when no file was supplied), rather than restoring current buffers, tabs, views, or session state. Final quit, errors, and panics restore normal input modes, the normal screen, cursor visibility, and the prior panic hook.
|
||||
@@ -237,7 +254,9 @@ config.bind "ctrl+h", nil, mode: config.modes.tree
|
||||
|
||||
The diff's changed-file explorer and floating picker use these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's comparison; Working checkout After reuses its editable buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan.
|
||||
|
||||
Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Escape closes the revision selector in either stage, but an opened comparison still closes explicitly through `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and exposes immutable `close`, `view.toggle`, and `orientation.toggle` children.
|
||||
Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Escape closes the revision selector in either stage, but an opened comparison still closes explicitly through `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and exposes immutable `close`, `mode.toggle`, `view.toggle`, and `orientation.toggle` children. New comparisons default to Branch changes (merge base → selected After); Normal-mode Ctrl+b, `:diff.mode.toggle`, or the palette toggles to direct Snapshots and back, preserving the selected endpoints. Selecting Working checkout on either side forces Snapshots.
|
||||
|
||||
`config.core.diff.review.toggle` is a non-mutating Normal-mode action, also available as `:diff.review.toggle` and in the palette. It marks/unmarks the highlighted changed-file leaf when the comparison's explorer is focused, or the current comparison file from a text pane. Read-only snapshots permit review marks. The default `c` shortcut applies only while browsing the diff's changed-file tree; explicit bindings and disabled keys override it. For example, `config.bind "ctrl+g", config.core.diff.review.toggle`; use the palette or a control-key binding while filtering, including List view, so printable input stays in the query.
|
||||
|
||||
`config.core.pane.close` and `config.core.pane.force_close` close a focused **docked** file list instead of its editor pane, without discarding buffers or exiting the comparison. A floating picker does not intercept these actions: normal close still protects underlying dirty buffers, while force-close retains its existing discard semantics. For example, bind `config.bind "ctrl+x", config.core.pane.close`; focused tree/selection overrides and printable filter input retain priority. Palette-originated close actions retain the originating dock target through force confirmation.
|
||||
|
||||
@@ -328,6 +347,8 @@ See [native LSP architecture](architecture.md#native-lsp) for synchronization an
|
||||
|
||||
`:lsp.definition` or the configured Space d binding requests a definition at the primary caret, once regardless of selection count. Both Location and LocationLink results are supported; multiple results use the first. Local-file navigation reuses loaded buffers without overwriting unsaved text. LSP UTF-16 positions and percent-encoded file URIs are translated to validated grapheme coordinates. Navigation is ignored if its source buffer, revision, caret, or latest request has changed.
|
||||
|
||||
Successful definition jumps center the destination caret's visual row once in its actual text pane, excluding bars and docks. This applies to ordinary buffers, new destination tabs, wrapped text, and split/unified diff panes. Near the beginning of the file, centering clamps to the first line; near EOF, blank space can remain below the centered target. Subsequent cursor motion resumes ordinary scrolling. Failed or stale requests do not request centering; Markdown links, search, and jump-history navigation retain their existing behavior.
|
||||
|
||||
For programmatic bindings, `config.core.lsp` exposes immutable Normal-mode Action objects `definition`, `diagnostics`, and `hover`, plus the **Insert-only** `completion` Action. Normal actions route to their corresponding Ex commands and have no default keys. Completion defaults to Insert Ctrl+n; override or disable it using an Insert binding. It requests directly without leaving Insert or committing pending typing; there is no Normal `:lsp.completion` command. These actions are also available in the command palette according to mode eligibility. The namespace is distinct from `config.lsp[language_id]` server configuration.
|
||||
|
||||
Interactive LSP commands use dotted names (`:lsp.hover`, `:lsp.definition`, `:lsp.diagnostics`). The existing whitespace separator keeps the whole dotted name separate from arguments. Current LSP commands take no arguments.
|
||||
|
||||
+46
-10
@@ -10,7 +10,7 @@ Default controls and editing behavior. For custom bindings, see [configuration](
|
||||
| --- | --- |
|
||||
| All modes | Ctrl+p opens the command palette; type to filter, Ctrl+j/k or arrows select, Enter accepts, Esc/Ctrl+p cancels |
|
||||
| Normal, Visual | Space Space opens the palette through the default leader |
|
||||
| Normal | Space w saves; Space b n/p/l/d switches next/previous, lists, or closes buffers |
|
||||
| Normal | Space w saves; Space b n/p/l/d switches next/previous, opens the buffer picker, or closes buffers |
|
||||
| Normal, Visual | Space o opens the floating tree in INSERT/filter mode; Space e opens/focuses the docked compact tree |
|
||||
| Normal | `i` insert before each caret; `a` insert after each caret; `o`/`O` open lines below/above |
|
||||
| Normal, Visual | `I` (Shift+i) / `A` (Shift+a) insert at first nonblank / line end; Visual splits each touched row into a caret first |
|
||||
@@ -22,11 +22,15 @@ Default controls and editing behavior. For custom bindings, see [configuration](
|
||||
| Normal, Visual | `f<char>` seeks forward on the current line; `F<char>` seeks backward; `2fx` finds the second `x` after each caret |
|
||||
| Normal | `gf` follows a Markdown file link or heading fragment at the primary caret |
|
||||
| Normal, Visual | `gt` goes to the first line; `gb` goes to the last line, at first nonblank; Visual extends every selection |
|
||||
| Normal, Visual | `<line>g` jumps immediately to that one-based source line at first nonblank (`42g`); clamps at EOF and extends Visual selections |
|
||||
| Normal | Ctrl+o goes to an older jump location; Ctrl+i/Tab goes forward; counts select multiple locations |
|
||||
| Normal, Visual | Decimal prefixes repeat cursor/word/search motions: `5j`, `3k`, `2w`, `4e`, `2n`; Esc cancels a pending count |
|
||||
| Normal | `J` (Shift+j) joins each targeted line with the next line |
|
||||
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
|
||||
| Normal | `dd` deletes whole lines; `cc` replaces them and enters Insert; leading counts work (`3dd`, `2cc`) |
|
||||
| Normal | `yy` copies whole lines without moving; `dd` deletes them; `cc` changes them and enters Insert; counts work (`3yy`, `2dd`, `2cc`) |
|
||||
| Normal | `D` deletes through line end; `C` changes that suffix and enters Insert; `Y` copies that suffix characterwise |
|
||||
| Normal | `r<char>` replaces the current grapheme; `s` deletes it and enters Insert |
|
||||
| Normal | `d<count>w` / `d<count>b` delete forward/backward word spans, e.g. `d2w` / `d2b` |
|
||||
| Normal, Visual | `.` repeats the last successful native edit against the current targets; one undo group |
|
||||
| Normal | `/` opens search; `n` finds the next match; `N` (Shift+n) finds the previous match |
|
||||
| Normal | `v` starts characterwise Visual; `V` starts linewise Visual; `p`/`P` paste the configured clipboard after/before each caret or row for linewise fragments (system by default); Esc keeps only the primary caret |
|
||||
@@ -85,16 +89,23 @@ Counts apply to `h`/`j`/`k`/`l`, arrows, `w`/`b`/`e`, `W`/`B`/`E`, `f<char>`/`F<
|
||||
|
||||
Unbound ASCII digits `1`–`9` start a count in Normal/Visual; subsequent digits extend it. `0` retains line-start behavior without a count and extends an existing count when bound to line-start. Explicit digit bindings, including disabled keys, take precedence. Count arithmetic saturates rather than overflowing; large counts stop at motion boundaries or wrap for search, without executing an unbounded repeat loop.
|
||||
|
||||
The status bar displays a pending count. Esc cancels it without moving, collapsing carets, or leaving Visual; the next Esc performs its ordinary action. Leader and `dd`/`cc` prefixes retain the count until their action resolves. Line actions use that count; other non-motion commands consume it and execute once, including line-start/end, other edits, and plugins. Unknown/disabled keys and bracketed paste clear it; opening the palette clears it, so palette query digits remain literal. Digits also remain literal in Insert and command/search prompts. Counts add no history, and general Vim operator grammar such as `3dw` is not supported.
|
||||
The status bar displays a pending count. Esc cancels it without moving, collapsing carets, or leaving Visual; the next Esc performs its ordinary action. Leader and editing prefixes retain the count until their action resolves. Line/word deletion, character replacement, and substitution use counts; suffix actions and other non-motion commands consume it and execute once. Unknown/disabled keys and bracketed paste clear it; opening the palette clears it, so palette query digits remain literal. Digits also remain literal in Insert and command/search prompts. Counts add no history; only the dedicated operators described below are supported.
|
||||
|
||||
## Normal line actions
|
||||
## Normal editing actions
|
||||
|
||||
`dd` cuts the current line and its newline into the configured linewise clipboard, staying in Normal. `cc` cuts the same whole-row contents, leaves one empty replacement row, and enters Insert at column 0; indentation is not retained. Leading counts select rows downward from each caret, clamped at EOF: `3dd` cuts up to three rows and `2cc` changes up to two rows. Overlapping ranges merge before editing or clipboard publication; adjacent ranges remain separate targets. Deleting at EOF removes the preceding separator when needed, and the buffer always retains at least one row.
|
||||
|
||||
The first `d` or `c` waits for its matching second key and appears in the status bar. Escape cancels only that pending prefix/count, preserving carets; an unmatched key cancels the prefix and then performs its ordinary action. Only leading counts are supported, not forms such as `d2d` or `d{motion}`. Explicit bindings, disabled first keys, and a leader using that first key take precedence.
|
||||
`yy` copies the entire current line into the configured linewise clipboard without moving any caret, changing text, or replacing undo/redo and dot-repeat history. Counts before and after `y` multiply (`2yy`, `y2y`, `2y3y`), selecting rows downward and clamping at EOF. Empty and unterminated final rows retain linewise shape. `p` pastes these complete rows below the current line; `P` pastes above it. The caret lands on the first nonblank of the first inserted row. Characterwise yanks still paste after/before the current grapheme. Bind `config.core.copy_line` for another Normal-mode route; historical read-only buffers permit yanking but reject paste.
|
||||
|
||||
The first `d`, `c`, or `y` waits for its second key and appears in the status bar. `d` accepts `d`, `w`, or `b`; `c` accepts `c`; `y` accepts `y`. Counts before and after the operator multiply: `2d3w` deletes six forward word spans, and `d2d` deletes two rows. Escape cancels only the pending prefix/count, preserving carets; an unmatched key cancels the prefix and then performs its ordinary action. Explicit bindings, disabled first keys, and a leader using that first key take precedence.
|
||||
|
||||
System clipboard publication must succeed before any deletion. Failure preserves text, carets, mode, undo/redo, and repeat intent. `dd` is one undo action; `cc` and subsequent replacement typing form one Insert undo group. Dot repeats the original requested row count at the current carets—even if the original action was clamped at EOF—and replays `cc` replacement text. Rebind the Normal-only `config.core.delete_line` / `config.core.change_line` actions to a single key or leader sequence without relying on the built-in doubled keys.
|
||||
|
||||
`D` cuts from each caret through its logical line end; `C` cuts the same suffix and enters Insert. `Y` copies that suffix characterwise without moving the carets. These actions leave line separators intact and ignore counts. `C` and subsequent typed replacement form one undo group.
|
||||
|
||||
`r<char>` replaces one grapheme per caret and stays in Normal; `s` cuts one grapheme and enters Insert. Counts select that many graphemes, clamped to the current line. Combining sequences and ZWJ emoji are never split. Replacement accepts a printable character or Tab literally; Escape or other non-character input cancels it without editing. Empty-line replacement is a no-op; substitution can still enter Insert. `s` and replacement typing form one undo group.
|
||||
|
||||
`dw` / `db` cut characterwise spans from each caret to the forward/backward word-start boundary, using the same Unicode word boundaries as `w` / `b`. Counts repeat the boundary motion; backward spans exclude the original caret character. These are dedicated actions, not arbitrary operator–motion grammar. Suffix, substitution, and word cuts share the configured clipboard's failure-before-edit protection; all native edits support undo and dot repeat.
|
||||
|
||||
## Selections and multi-caret editing
|
||||
|
||||
@@ -106,7 +117,7 @@ Copy and cut return to Normal at each selection start; Visual cancellation keeps
|
||||
|
||||
Visual `c` removes all selected text into the configured clipboard and enters Insert at each selection start. In the default System policy, clipboard publication must succeed before any deletion; failures leave text and selections intact. Deletion and replacement typing form one undo action when Insert mode is exited, including through explicit command execution. Esc without typing commits the deletion alone. An identical replacement creates no history entry and preserves redo. Changing only characterwise empty final-row selections enters Insert without overwriting either clipboard; linewise empty final rows are genuine rows and publish a newline.
|
||||
|
||||
Bracketed paste broadcasts the same literal text to all Insert carets in a separate undo group, distinct from typing before/after it. A rejected paste leaves the pending change intact. Normal supports counted `dd` and `cc`, but not general operator grammar such as `cw`, `dw`, or `yy`; blockwise Visual is also unsupported. `c` and `d` type literal text in Insert mode.
|
||||
Bracketed paste broadcasts the same literal text to all Insert carets in a separate undo group, distinct from typing before/after it. A rejected paste leaves the pending change intact. Normal supports counted `yy`, `dd`, `cc`, `dw`, and `db`, but not general operator grammar such as `cw`; blockwise Visual is also unsupported. `c`, `d`, and `y` type literal text in Insert mode.
|
||||
|
||||
Clipboard policy is session-wide and defaults to the system clipboard. Copy/cut store fragments in document order: one fragment broadcasts to every caret; matching fragment/caret counts pair in document order; any other count refuses paste without changing editing state. Empty clipboard paste does not edit text; copying/cutting only characterwise empty final-row selections does not overwrite either register. Linewise copy/cut retain whole-row shape, including a newline fragment for an empty final row. Open a new search or enter Ex commands after leaving Visual mode. Plugin commands execute once, not once per target: successful text effects collapse to the primary and apply once; rejected or message-only effects preserve the selection set. There is no selection-aware plugin API yet.
|
||||
|
||||
@@ -126,7 +137,7 @@ For other applications, characterwise multi-selection exports join fragments wit
|
||||
|
||||
System paste validates UTF-8, unsupported controls, newlines, and a 4 MiB limit before editing; CRLF/CR normalize to LF. System exports also have a 4 MiB limit. Provider failures report an error without deleting/changing buffer text, caret/selection state, history, or repeat intent. No silent internal fallback occurs in System mode; choose Separate if clipboard access is unavailable. A native provider that fails after publishing may already have changed OS clipboard formats; vamp cannot roll that external side effect back.
|
||||
|
||||
System clipboard access supports macOS through `osascript`, Linux Wayland through `wl-paste`/`wl-copy`, and Linux X11 through `xclip`. Linux tools must be on PATH with the corresponding display environment. Other platforms report unsupported access; choose Separate mode when system clipboard access is unavailable.
|
||||
macOS copies publish UTF-8 plain text directly through the native pasteboard API, avoiding interpreter/process startup for every copy or cut; paste reads still use `osascript` with JXA/AppKit. Linux Wayland uses `wl-paste`/`wl-copy`, and Linux X11 uses `xclip`. Linux tools must be on PATH with the corresponding display environment. Other platforms report unsupported access; choose Separate mode when system clipboard access is unavailable.
|
||||
|
||||
## Repeat last edit
|
||||
|
||||
@@ -198,6 +209,14 @@ Toggling preserves the source caret and anchors a visible caret to its previous
|
||||
|
||||
Text wraps at word boundaries where possible, with grapheme-safe breaks for long words. Whitespace is preserved; tabs keep their source-column stops and wide/combining Unicode characters retain their geometry. Only the first display segment shows a line number. Syntax and semantic colors, diagnostics, search matches, selections, and cursors follow the wrapped text. Motions and counts still use source lines, so `j`/`k`, Home/End, and saved file contents are unchanged. Vertical wheel input scrolls continuation rows; switching wrapping off restores horizontal scrolling.
|
||||
|
||||
## Current-line Git blame
|
||||
|
||||
Inline blame is enabled by default in ordinary live file buffers. The primary caret's line shows its author, commit summary, and short commit ID after the document text; changed or newly inserted tracked-file lines show **Uncommitted**, including unsaved edits. Git-backed jj repositories use the working-copy revision's single parent, not a potentially stale Git HEAD. Merge working copies with multiple parents omit attribution.
|
||||
|
||||
Loading happens in the background without network access or changing repository refs/checkouts. Attribution is loaded once for the active buffer's in-memory contents; navigation then selects cached lines immediately, and movement during a cold load does not restart it. Editing, saving, reloading, changing source buffers, regaining terminal focus, or toggling blame invalidates the cached attribution. Missing, untracked, unborn, and non-repository files have no indicator; immutable historical buffers and comparison views do not receive live-file attribution. The indicator is hidden when the caret is offscreen or the full label cannot fit; wrapped lines use their final visible continuation without covering text, selections, or the caret. Popups retain their normal layering.
|
||||
|
||||
Use `:git_blame.toggle`, the palette action, or remappable `config.core.git_blame.toggle`; there is no default keybinding. Set `config.git_blame = false` to disable it at startup. Disabling stops the worker and clears the label without changing text or undo history.
|
||||
|
||||
## Panes and splits
|
||||
|
||||
Splits can be nested in either direction. The new pane receives focus. Panes showing the same buffer share text, saved state, diagnostics, and undo/redo history, but retain independent carets, selections, modes, searches, and vertical/horizontal viewports. Buffer navigation changes only the focused pane. A file is still loaded once, even when displayed in several panes.
|
||||
@@ -259,7 +278,7 @@ Each buffer retains its shared text, path, saved state, and undo/redo history. V
|
||||
| Command | Behavior |
|
||||
| --- | --- |
|
||||
| `:edit PATH` | Switch to an already-loaded file, or read and validate a new buffer; a nonexistent file opens empty |
|
||||
| `:buffers` | List stable IDs and paths; `%` marks active, `+` marks dirty |
|
||||
| `:buffers` | Open the searchable buffer picker; stable IDs and paths, `%` active, `+` dirty |
|
||||
| `:buffer N` | Switch to buffer ID `N` |
|
||||
| `:buffer_next` / `:buffer_previous` | Switch in ascending-ID order, wrapping at either end |
|
||||
| `:buffer_delete [N]` | Close the active or named buffer; refuse unsaved changes |
|
||||
@@ -269,6 +288,10 @@ IDs start at 1 and are never renumbered or reused. Closing the last buffer creat
|
||||
|
||||
Switching from Insert commits its pending undo group and parks that view in Normal. Explicit buffer switching from Visual retains the pane's directed ranges and Visual mode for that buffer; switching back restores them. Command/search prompts, palette queries, and pending motion counts are transient, not restored per buffer. Buffer commands appear in the command palette and can be bound by their typed core actions in Normal mode. When several buffers are open, status shows the active ID and total count.
|
||||
|
||||
**Space b l**, `:buffers`, or the **Buffers** command-palette action opens the picker. Type to filter IDs and paths with case-insensitive, whitespace-separated substring tokens; every token must match. Results retain ascending ID order. Ctrl+j/Ctrl+n/Down and Ctrl+k/Up select with wraparound, using the shared Selection bindings and overrides. Backspace edits the query; Ctrl+u clears it. Enter switches only the focused pane to the selected buffer without saving or discarding edits; no matches leave the picker open. Esc or Ctrl+p cancels without switching. Printable keys, including `:` and leader keys, belong to the query.
|
||||
|
||||
The picker includes ordinary buffers from every tab and unnamed buffers as `[No Name]`; immutable diff snapshots are excluded. Labels sanitize path control characters, but selection uses stable buffer IDs, so displayed-name collisions still select the correct buffer. Switching out of a diff follows ordinary `:buffer N` behavior.
|
||||
|
||||
## File picker and explorer
|
||||
|
||||
Space o opens a floating **Tree** explorer directly in **INSERT/filter** mode, ready for typing; Space e opens or focuses the docked **Tree**, starting in **NORMAL/navigation** mode when new. Both use one catalog and instance. Invoking the other action applies that presentation's configured default while retaining query, selection identities, and surviving folder expansion. Floating Tree invocation enters filtering if not already filtering. These actions are available as `config.core.file_picker` and `config.core.file_explorer` in Normal/Visual and in the command palette.
|
||||
@@ -311,13 +334,16 @@ The project root is the working directory captured at startup. Scanning uses the
|
||||
|
||||
Run `:diff` in Normal mode, choose **Before**, then **After**, and press Enter to compare. The command palette also exposes **Diff**; there is no default keybinding. Repository discovery starts at the active file's directory, or the startup project root for an unnamed buffer, and prefers jj over Git.
|
||||
|
||||
The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / Branches / Tags** for Git. Type to filter by revision/change ID, commit ID, reference name, or description. **Working checkout** stays first in every category, even when it does not match the query. As Before it is a disk snapshot, including eligible untracked files. As After it includes unsaved regular-text buffers and enables live editing; opening a comparison never saves them.
|
||||
The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits / Branches / Tags** for Git. Both Before and After support case-insensitive fuzzy search by revision/change ID, commit ID, reference name, or description: `f1205pas` can find `sm/fin-1205-pilot-assignment-schema`. Space-separated tokens must all match, but can match different fields in any order; a single token cannot span unrelated fields. Exact, prefix, and contiguous matches rank ahead of scattered subsequences; otherwise equal matches favor names/IDs over descriptions, word boundaries, and tighter spans. Ties retain catalogue order, and an empty or whitespace-only query restores the original order. **Working checkout** stays first in every category, even when it does not match the query. As Before it is a disk snapshot, including eligible untracked files. As After it includes unsaved regular-text buffers and enables live editing; opening a comparison never saves them.
|
||||
|
||||
New comparisons default to **Branch changes**: Before becomes the unique merge base of the selected Before and After revisions, and After stays the selected tip. Changes exclusive to the Before branch are excluded. The Before border identifies both selected names and the immutable merge-base commit ID. **Snapshots** compares the two selected snapshots directly. **Ctrl+b** toggles the mode, also available as **`:diff.mode.toggle`**, the palette action, and remappable **`config.core.diff.mode.toggle`**. Toggling an opened comparison recomputes using the original selected endpoints. Selecting **Working checkout** on either side immediately forces **Snapshots**; Branch changes is unavailable while it is selected. Missing or multiple merge bases produce an explicit, recoverable selector error rather than silently falling back; choose Snapshots to compare such histories. jj's synthetic root is the empty ancestor of every jj history.
|
||||
|
||||
| Selector input | Effect |
|
||||
| --- | --- |
|
||||
| Tab / Shift+Tab | Next / previous category |
|
||||
| Up / Down, Ctrl+k / Ctrl+j | Previous / next entry |
|
||||
| Backspace / Ctrl+u | Remove a query grapheme / clear query |
|
||||
| Ctrl+b | Toggle Branch changes / Snapshots (Working checkout requires Snapshots) |
|
||||
| Enter | Choose the entry |
|
||||
| Esc | Close either selector stage, cancel outstanding work, and restore the source buffer without discarding edits |
|
||||
|
||||
@@ -327,6 +353,10 @@ The selector initially lists local revisions and known tracking refs **without c
|
||||
|
||||
Comparison discovery builds the changed-path/status catalog first. File text, notices, and hunks load asynchronously when a file is selected and remain cached for that comparison. Failed file loads can be retried by selecting the file again; cancelled or superseded results cannot replace the current selection. Working-checkout comparisons still capture working bytes and the historical content needed to identify changes, preserving the frozen Before snapshot and unsaved After buffers.
|
||||
|
||||
**Working checkout After follows forward checkouts automatically**, checked asynchronously once per second, including inactive tabs. A descendant jj change or Git HEAD rebuilds the changed-file catalog and captures current working bytes plus eligible unsaved buffers, while keeping the original Before frozen. jj tracking uses change IDs, so snapshot/description rewrites of the same change do not advance the endpoint or prevent following its later descendants. Same, backward, and unrelated checkouts do not rebuild the catalog; live-buffer editing and external reload still work normally. Historical After never follows. Set `config.diff_follow_working_copy = false` to disable following.
|
||||
|
||||
Refresh retains the selected path when still present, explorer query/view/visibility, logical text-side focus, and split/unified/orientation/wrap choices; it clears review marks for the new endpoint. Removed selections fall back to the first changed file. Failed refreshes keep the previous comparison visible. Superseded file loads and LSP navigation cannot apply to the new comparison; snapshots still displayed in other panes or tabs remain read-only.
|
||||
|
||||
The changed-file explorer is **docked on the right**. In the default split layout, modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain both source sides. Before and historical/remote After snapshots are read-only. Selecting **Working checkout** as After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. Editable After supports native edits, completion, undo/redo, and `:w`. Removed files, rename-only files, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits.
|
||||
|
||||
Comparisons start in **split, vertical (left/right)** view by default. In Normal mode, **Space v** cycles split/unified; **Space s** switches a split between vertical (left/right) and horizontal (above/below, Before above After). Canonical commands are `:diff.view.toggle` and `:diff.orientation.toggle`; both are available in the palette and as remappable `config.core.diff.view.toggle` / `config.core.diff.orientation.toggle` actions. Configure new-comparison defaults with `config.diff_view_mode = "split"` or `"unified"` and `config.diff_split_orientation = "vertical"` or `"horizontal"`. Current choices survive file and tab switches; closing and reopening uses the configured defaults. Orientation stays unchanged while unified is active.
|
||||
@@ -341,12 +371,18 @@ Text panes use normal buffer interaction regardless of writability: motions and
|
||||
|
||||
**LSP definition, hover, and diagnostics work normally in focused Before and After text panes**, including historical snapshots. Each snapshot is served with its immutable revision text and its own document URI, not the current disk contents. Completion and completion edits are available only in writable Working-checkout After; they use the shared buffer's normal undo history. Revision selectors and the changed-file explorer are not document-action targets.
|
||||
|
||||
Accepted LSP definitions center the selection's caret row once in the destination pane, including wrapped visual rows and revealed diff excerpts. Centering uses the focused pane's actual height in either split orientation or unified view. The next motion does not recenter; failed or superseded definitions preserve the current logical viewport.
|
||||
|
||||
A definition whose source range **overlaps an actual added, removed, or replaced line** stays in the same comparison and selects the correct file and side, loading file details on demand. For a location link, the symbol's full target range determines overlap and its selection range determines the caret: an unchanged declaration with a changed body still belongs in the comparison. Source coordinates belong to that side, including old paths for renames; navigation reveals the exact selection row, even when previously omitted, never the nearest excerpt. A wholly unchanged target opens a **new ordinary tab**, even when it is visible context or omitted text in a changed file. Files outside the comparison's changes also open in a new tab. The original comparison, pane layout, source caret, excerpts, and dirty buffers remain intact. Loaded targets share their existing text and undo history rather than reloading disk; references to snapshot URIs retain the historical text and read-only identity in the new tab, even after the comparison closes. Unreadable files, invalid UTF-8, invalid source ranges, and unsupported remote URIs do not create a tab or disturb the source.
|
||||
|
||||
Use the ordinary **file-picker/file-explorer actions and bindings**: Space `o` opens a floating picker, Space `e` opens/focuses the docked explorer, and Ctrl+w closes the focused picker/explorer and reclaims its width. Remapped bindings work too; Ctrl+e is no longer reserved by the diff. The changed-path catalog retains A/M/D/R markers and the normal compact tree, filtering, folder expansion, and configurable `tree`/`selection` navigation. It includes removed and historical paths without scanning the working directory. Enter toggles a folder or opens the selected comparison file and focuses its first visible text pane: After for added files, otherwise Before. While the explorer is hidden, Tab/Shift+Tab cycles between visible text panes only; a single-pane view keeps its focus.
|
||||
|
||||
File rows show colored **A** (added, green), **M** (modified, yellow), **D** (removed, red), or **R** (renamed, cyan) markers in Tree and List views, including filtered results. Rename rows show `R old/path -> new/path`; directories have no file-status marker. Ordinary file exploration remains unannotated.
|
||||
|
||||
Mark or unmark a diff file with **`c` while browsing the changed-file tree**, **`:diff.review.toggle`**, or the command-palette action. With the changed-file explorer focused, it toggles the highlighted file without opening it; from a text pane, the command toggles the displayed file. Reviewed files show a green **`✓` immediately before A/M/D/R** in Tree and List views. Directories and ordinary filesystem pickers cannot be marked. While filtering, including List view, `c` remains query text; use the palette or a control-key binding there. Explicit bindings take precedence over the default browsing shortcut.
|
||||
|
||||
Review marks belong only to the open comparison and survive filtering, file navigation, lazy loading, tabs, and split/unified/orientation changes. A new or recomputed comparison clears them. Editing a Working After file clears only that file's mark; unchanged refreshes and saves preserve it. Marks do not change document text, undo history, or repository state, and are not persisted after closing the comparison.
|
||||
|
||||
Text panes show change excerpts with **10 unchanged lines above and below** each change, preserving original file line numbers. Overlapping or touching excerpts merge; omitted stretches show an unchanged-line count in both panes. Files with only metadata changes show their notice or “No text changes” rather than unrelated source.
|
||||
|
||||
In **Normal mode** with a text pane focused, **`[` expands above** and **`]` expands below** the excerpt containing the caret, revealing up to **15 lines** per keypress in either pane. In editable After's Insert mode these keys insert literal text. Expansion clamps at file boundaries and merges excerpts when they meet. Existing visible source stays anchored. Opening a file resets its excerpts to the configured initial context. Live edits recompute changes while retaining expanded context; the caret stays visible even when the last text change is reverted.
|
||||
@@ -365,7 +401,7 @@ Character-wise highlighting is enabled automatically within paired changed lines
|
||||
|
||||
Diff backgrounds blend the terminal's default background with its red/green palette colors: an 18% accent for changed rows and a stronger 38% accent for changed characters, both computed from the original background. Read-only OSC 11/4 queries run after the initial paint, on focus regain, and whenever a subscribed DEC 2031 color-scheme notification arrives. While a diff is visible in any pane, a nonblocking one-second palette probe also catches external theme changes without a notification, including changes between two themes with the same light/dark preference. Ordinary editor views do not poll; cached colors apply across editor panes. If the complete palette is unavailable within 250 ms, change backgrounds remain normal and gutter colors/markers still identify changes.
|
||||
|
||||
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, with the same terminal `DarkGray` foreground as ordinary gutter text. 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.
|
||||
|
||||
In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Switching Before/After synchronizes the primary cursor by aligned diff row and display column. A missing counterpart uses the nearest visible source row; shorter lines clamp the column while retaining the preferred display column for the return trip. Vertical wheel input scrolls the visible panes without moving the focused text caret; switching sides by vertical wheel also synchronizes the cursor. Over Files it navigates visible tree rows without opening them. Horizontal wheel input in either split synchronizes the Before/After horizontal offsets, bounded by the longer side, without changing focus, cursor, or selections; over Files it is consumed. Added/deleted files retain single-pane scrolling. While a comparison is open, Escape cancels modes, prompts, or filtering, or returns picker focus to the last bound text pane; it leaves the comparison open. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias.
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
Vamp.configure do |config|
|
||||
config.leader = "space"
|
||||
config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal
|
||||
config.git_blame = true # false disables current-line inline blame
|
||||
config.file_explorer_side = "right"
|
||||
config.file_explorer_view = "tree"
|
||||
config.file_picker_view = "tree"
|
||||
config.diff_view_mode = "split" # or "unified"
|
||||
config.diff_split_orientation = "vertical" # or "horizontal"
|
||||
config.diff_follow_working_copy = true # false keeps the selected Working After endpoint
|
||||
# config.popup.command = { anchor: :bottom_left }
|
||||
# config.popup.search = { anchor: :center }
|
||||
# Status above, tabs and live battery below; either bar accepts all segments.
|
||||
|
||||
+867
@@ -0,0 +1,867 @@
|
||||
//! Read-only file attribution; repository commands never touch the editor thread.
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
io::{self, Read, Write},
|
||||
path::{Component, Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
sync::{
|
||||
Arc, Condvar, Mutex,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const LIMIT: usize = 16 * 1024 * 1024;
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
||||
type ResultFile = Result<Option<Vec<Option<Arc<LineBlame>>>>, String>;
|
||||
type Request = (u64, u64, PathBuf, String);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) struct LineBlame {
|
||||
pub(crate) commit: String,
|
||||
pub(crate) author: String,
|
||||
pub(crate) summary: String,
|
||||
pub(crate) uncommitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
request: Option<Request>,
|
||||
result: Option<(u64, ResultFile)>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Shared {
|
||||
state: Mutex<State>,
|
||||
wake: Condvar,
|
||||
generation: AtomicU64,
|
||||
stopped: AtomicBool,
|
||||
}
|
||||
pub(crate) struct BlameWorker {
|
||||
shared: Arc<Shared>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
impl BlameWorker {
|
||||
pub(crate) fn start() -> io::Result<Self> {
|
||||
let shared = Arc::new(Shared::default());
|
||||
let worker = Arc::clone(&shared);
|
||||
let thread = thread::Builder::new()
|
||||
.name("vamp-blame".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let Ok(mut state) = worker.state.lock() else {
|
||||
return;
|
||||
};
|
||||
while state.request.is_none() && !worker.stopped.load(Ordering::Acquire) {
|
||||
let Ok(next) = worker.wake.wait(state) else {
|
||||
return;
|
||||
};
|
||||
state = next;
|
||||
}
|
||||
if worker.stopped.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
let (generation, id, path, contents) = state.request.take().unwrap();
|
||||
drop(state);
|
||||
let cancelled = || {
|
||||
worker.stopped.load(Ordering::Acquire)
|
||||
|| worker.generation.load(Ordering::Acquire) != generation
|
||||
};
|
||||
let result = read_file(&path, &contents, &cancelled);
|
||||
let Ok(mut state) = worker.state.lock() else {
|
||||
return;
|
||||
};
|
||||
if !cancelled() {
|
||||
state.result = Some((id, result));
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Self {
|
||||
shared,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn request(&mut self, id: u64, path: PathBuf, contents: String) -> io::Result<()> {
|
||||
if contents.len() > LIMIT {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Blame input exceeds 16 MiB",
|
||||
));
|
||||
}
|
||||
let mut state = self
|
||||
.shared
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("Blame worker stopped"))?;
|
||||
let generation = self
|
||||
.shared
|
||||
.generation
|
||||
.fetch_add(1, Ordering::AcqRel)
|
||||
.wrapping_add(1);
|
||||
state.request = Some((generation, id, path, contents));
|
||||
state.result = None;
|
||||
self.shared.wake.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn poll(&mut self) -> Option<(u64, ResultFile)> {
|
||||
self.shared.state.lock().ok()?.result.take()
|
||||
}
|
||||
}
|
||||
impl Drop for BlameWorker {
|
||||
fn drop(&mut self) {
|
||||
let state = self.shared.state.lock();
|
||||
self.shared.stopped.store(true, Ordering::Release);
|
||||
self.shared.wake.notify_one();
|
||||
drop(state);
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Temporary(PathBuf);
|
||||
impl Temporary {
|
||||
fn new() -> Result<Self, String> {
|
||||
for _ in 0..100 {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"vamp-blame-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let mut builder = fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
builder.mode(0o700);
|
||||
}
|
||||
match builder.create(&path) {
|
||||
Ok(()) => return Ok(Self(path)),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(_) => return Err("Cannot create private blame directory".into()),
|
||||
}
|
||||
}
|
||||
Err("Cannot create private blame directory".into())
|
||||
}
|
||||
}
|
||||
impl Drop for Temporary {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Same process-group cancellation as repository comparisons, with a smaller output/time budget.
|
||||
fn run(
|
||||
mut command: Command,
|
||||
input: &[u8],
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
if cancelled() {
|
||||
return Err("Blame cancelled".into());
|
||||
}
|
||||
for key in [
|
||||
"GIT_DIR",
|
||||
"GIT_COMMON_DIR",
|
||||
"GIT_WORK_TREE",
|
||||
"GIT_INDEX_FILE",
|
||||
"GIT_OBJECT_DIRECTORY",
|
||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
] {
|
||||
command.env_remove(key);
|
||||
}
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_NO_REPLACE_OBJECTS", "1")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
return Err("Blame requires Unix process-group cancellation".into());
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
command.process_group(0);
|
||||
let mut child = command.spawn().map_err(|_| "Cannot start blame command")?;
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
thread::scope(|scope| {
|
||||
let output = scope.spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
stdout
|
||||
.take(LIMIT as u64 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| "Cannot read blame output")?;
|
||||
if bytes.len() > LIMIT {
|
||||
return Err("Blame output exceeds 16 MiB");
|
||||
}
|
||||
Ok(bytes)
|
||||
});
|
||||
let writer = scope.spawn(move || stdin.write_all(input));
|
||||
let started = Instant::now();
|
||||
let mut status = None;
|
||||
let failure = loop {
|
||||
if cancelled() {
|
||||
break Some("Blame cancelled");
|
||||
}
|
||||
if started.elapsed() >= TIMEOUT {
|
||||
break Some("Blame command timed out");
|
||||
}
|
||||
if status.is_none() {
|
||||
match child.try_wait() {
|
||||
Ok(value) => status = value,
|
||||
Err(_) => break Some("Cannot wait for blame command"),
|
||||
}
|
||||
}
|
||||
if status.is_some() && output.is_finished() && writer.is_finished() {
|
||||
break None;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
if failure.is_some() {
|
||||
unsafe {
|
||||
unsafe extern "C" {
|
||||
fn kill(pid: i32, signal: i32) -> i32;
|
||||
}
|
||||
kill(-(child.id() as i32), 9);
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
let bytes = output.join().map_err(|_| "Blame reader stopped")?;
|
||||
let written = writer.join().map_err(|_| "Blame writer stopped")?;
|
||||
if let Some(error) = failure {
|
||||
return Err(error.into());
|
||||
}
|
||||
let bytes = bytes.map_err(str::to_owned)?;
|
||||
if !status.unwrap().success() {
|
||||
return Ok(None);
|
||||
}
|
||||
written.map_err(|_| "Cannot supply blame contents")?;
|
||||
Ok(Some(bytes))
|
||||
})
|
||||
}
|
||||
}
|
||||
fn git(root: &Path) -> Command {
|
||||
let mut command = Command::new("git");
|
||||
command
|
||||
.args([
|
||||
"--no-pager",
|
||||
"--literal-pathspecs",
|
||||
"-c",
|
||||
"core.hooksPath=/dev/null",
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
])
|
||||
.arg("-C")
|
||||
.arg(root);
|
||||
command
|
||||
}
|
||||
fn oid(value: &str) -> bool {
|
||||
matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
fn path_bytes(bytes: &[u8]) -> Result<PathBuf, String> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
|
||||
Ok(PathBuf::from(OsString::from_vec(bytes.to_vec())))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
Ok(PathBuf::from(
|
||||
std::str::from_utf8(bytes).map_err(|_| "Invalid repository path")?,
|
||||
))
|
||||
}
|
||||
}
|
||||
fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> ResultFile {
|
||||
if contents.len() > LIMIT {
|
||||
return Err("Blame input exceeds 16 MiB".into());
|
||||
}
|
||||
// The editor has a final empty row after a newline; Git has no corresponding source line.
|
||||
let rows = contents.split_inclusive('\n').count();
|
||||
if rows == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
if !fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_file()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = fs::canonicalize(path).map_err(|_| "Cannot resolve blame file")?;
|
||||
let Some(root) = path.parent().and_then(|parent| {
|
||||
parent
|
||||
.ancestors()
|
||||
.find(|root| root.join(".jj").exists() || root.join(".git").exists())
|
||||
}) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let relative = path.strip_prefix(root).map_err(|_| "Invalid blame path")?;
|
||||
if relative.components().any(|part| {
|
||||
!matches!(part, Component::Normal(_))
|
||||
|| part.as_os_str() == ".git"
|
||||
|| part.as_os_str() == ".jj"
|
||||
}) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut temporary = None;
|
||||
let mut command = git(root);
|
||||
if root.join(".jj").exists() {
|
||||
let mut jj = Command::new("jj");
|
||||
jj.arg("--repository")
|
||||
.arg(root)
|
||||
.args([
|
||||
"--ignore-working-copy",
|
||||
"--at-operation=@",
|
||||
"--no-pager",
|
||||
"--color=never",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-r",
|
||||
"@-",
|
||||
"-T",
|
||||
"commit_id ++ \"\\n\"",
|
||||
])
|
||||
.current_dir(root);
|
||||
let bytes = run(jj, &[], cancelled)?.ok_or("Cannot resolve Jujutsu blame parent")?;
|
||||
let text = std::str::from_utf8(&bytes).map_err(|_| "Invalid Jujutsu blame metadata")?;
|
||||
let mut parents = text.lines();
|
||||
let Some(parent) = parents.next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
// A merge has no single committed baseline; never pick an arbitrary parent.
|
||||
if parents.next().is_some() || parent.bytes().all(|byte| byte == b'0') {
|
||||
return Ok(None);
|
||||
}
|
||||
if !oid(parent) {
|
||||
return Err("Invalid Jujutsu blame metadata".into());
|
||||
}
|
||||
let mut repo = root.join(".jj/repo");
|
||||
if repo.is_file() {
|
||||
let pointer = fs::read(&repo).map_err(|_| "Cannot read Jujutsu repository pointer")?;
|
||||
repo = root
|
||||
.join(".jj")
|
||||
.join(path_bytes(pointer.strip_suffix(b"\n").unwrap_or(&pointer))?);
|
||||
}
|
||||
let store = repo.join("store");
|
||||
let target = fs::read(store.join("git_target"))
|
||||
.map_err(|_| "Only Git-backed Jujutsu blame is supported")?;
|
||||
let store = fs::canonicalize(
|
||||
store.join(path_bytes(target.strip_suffix(b"\n").unwrap_or(&target))?),
|
||||
)
|
||||
.map_err(|_| "Cannot resolve Jujutsu Git store")?;
|
||||
let private = Temporary::new()?;
|
||||
fs::create_dir(private.0.join("refs"))
|
||||
.map_err(|_| "Cannot prepare private blame repository")?;
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(store.join("objects"), private.0.join("objects"))
|
||||
.map_err(|_| "Cannot link blame objects")?;
|
||||
#[cfg(not(unix))]
|
||||
return Err("Jujutsu blame requires Unix object links".into());
|
||||
fs::write(private.0.join("HEAD"), format!("{parent}\n"))
|
||||
.map_err(|_| "Cannot prepare blame parent")?;
|
||||
command
|
||||
.arg("--git-dir")
|
||||
.arg(&private.0)
|
||||
.arg("--work-tree")
|
||||
.arg(root);
|
||||
temporary = Some(private);
|
||||
}
|
||||
let mut present = if let Some(private) = &temporary {
|
||||
let mut command = git(root);
|
||||
command.arg("--git-dir").arg(&private.0);
|
||||
command
|
||||
} else {
|
||||
git(root)
|
||||
};
|
||||
let mut object = std::ffi::OsString::from("HEAD:");
|
||||
object.push(relative);
|
||||
present.args(["cat-file", "-e"]).arg(object);
|
||||
if run(present, &[], cancelled)?.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
command
|
||||
.args([
|
||||
"blame",
|
||||
"--incremental",
|
||||
"--no-textconv",
|
||||
"--contents",
|
||||
"-",
|
||||
"--",
|
||||
])
|
||||
.arg(relative);
|
||||
let output = run(command, contents.as_bytes(), cancelled)?.ok_or("Cannot read Git blame")?;
|
||||
parse(&output, rows).map(Some)
|
||||
}
|
||||
fn parse(bytes: &[u8], rows: usize) -> Result<Vec<Option<Arc<LineBlame>>>, String> {
|
||||
let malformed = || "Invalid Git blame metadata".to_owned();
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| malformed())?;
|
||||
let mut lines = text.lines();
|
||||
let mut commits = HashMap::<&str, Arc<LineBlame>>::new();
|
||||
let mut result = vec![None; rows];
|
||||
while let Some(line) = lines.next() {
|
||||
let mut header = line.split(' ');
|
||||
let commit = header.next().filter(|id| oid(id)).ok_or_else(malformed)?;
|
||||
let original = header.next().and_then(|n| n.parse::<usize>().ok());
|
||||
let start = header.next().and_then(|n| n.parse::<usize>().ok());
|
||||
let count = header.next().and_then(|n| n.parse::<usize>().ok());
|
||||
let (Some(original), Some(start), Some(count)) = (original, start, count) else {
|
||||
return Err(malformed());
|
||||
};
|
||||
if original == 0 || start == 0 || count == 0 || header.next().is_some() {
|
||||
return Err(malformed());
|
||||
}
|
||||
original.checked_add(count - 1).ok_or_else(malformed)?;
|
||||
let start = start - 1;
|
||||
let end = start
|
||||
.checked_add(count)
|
||||
.filter(|&end| end <= rows)
|
||||
.ok_or_else(malformed)?;
|
||||
let mut author = None;
|
||||
let mut summary = None;
|
||||
let mut filename = false;
|
||||
for line in lines.by_ref() {
|
||||
if let Some(value) = line.strip_prefix("filename ") {
|
||||
if value.is_empty() {
|
||||
return Err(malformed());
|
||||
}
|
||||
filename = true;
|
||||
break;
|
||||
}
|
||||
if line.is_empty() || line.starts_with('\t') || line.split(' ').next().is_some_and(oid)
|
||||
{
|
||||
return Err(malformed());
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("author ") {
|
||||
if author.replace(value).is_some() {
|
||||
return Err(malformed());
|
||||
}
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("summary ") {
|
||||
if summary.replace(value).is_some() {
|
||||
return Err(malformed());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !filename {
|
||||
return Err(malformed());
|
||||
}
|
||||
let blame = if let Some(blame) = commits.get(commit) {
|
||||
if author.is_some() || summary.is_some() {
|
||||
return Err(malformed());
|
||||
}
|
||||
Arc::clone(blame)
|
||||
} else {
|
||||
let author = author.ok_or_else(malformed)?;
|
||||
let summary = summary.ok_or_else(malformed)?;
|
||||
let uncommitted = commit.bytes().all(|byte| byte == b'0');
|
||||
let blame = Arc::new(LineBlame {
|
||||
commit: if uncommitted {
|
||||
String::new()
|
||||
} else {
|
||||
commit.into()
|
||||
},
|
||||
author: if uncommitted {
|
||||
String::new()
|
||||
} else {
|
||||
author.into()
|
||||
},
|
||||
summary: if uncommitted {
|
||||
String::new()
|
||||
} else {
|
||||
summary.into()
|
||||
},
|
||||
uncommitted,
|
||||
});
|
||||
commits.insert(commit, Arc::clone(&blame));
|
||||
blame
|
||||
};
|
||||
for row in &mut result[start..end] {
|
||||
if row.replace(Arc::clone(&blame)).is_some() {
|
||||
return Err(malformed());
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.iter().any(Option::is_none) {
|
||||
return Err(malformed());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn command(root: &Path, program: &str, args: &[&str]) -> String {
|
||||
let mut command = Command::new(program);
|
||||
if program == "jj" {
|
||||
command.args([
|
||||
"--config=user.name=\"Jj Author\"",
|
||||
"--config=user.email=\"jj@example.test\"",
|
||||
]);
|
||||
command.args([
|
||||
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||
]);
|
||||
}
|
||||
let output = command
|
||||
.args(args)
|
||||
.current_dir(root)
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
||||
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
|
||||
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
|
||||
.env("JJ_CONFIG", "/dev/null")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().into()
|
||||
}
|
||||
fn fixture() -> (Temporary, PathBuf, String) {
|
||||
let temp = Temporary::new().unwrap();
|
||||
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||
command(&temp.0, "git", &["config", "user.name", "Blame Author"]);
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["config", "user.email", "blame@example.test"],
|
||||
);
|
||||
let path = temp.0.join("-literal [file].txt");
|
||||
fs::write(&path, "first\nsecond\n").unwrap();
|
||||
command(&temp.0, "git", &["add", "--", "-literal [file].txt"]);
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["commit", "--quiet", "-m", "Original summary"],
|
||||
);
|
||||
let commit = command(&temp.0, "git", &["rev-parse", "HEAD"]);
|
||||
(temp, path, commit)
|
||||
}
|
||||
#[test]
|
||||
fn committed_line_has_native_author_summary_and_commit() {
|
||||
let (_temp, path, commit) = fixture();
|
||||
let rows = read_file(&path, "first\nsecond\n", &|| false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert!(Arc::ptr_eq(
|
||||
rows[0].as_ref().unwrap(),
|
||||
rows[1].as_ref().unwrap()
|
||||
));
|
||||
assert_eq!(
|
||||
**rows[1].as_ref().unwrap(),
|
||||
LineBlame {
|
||||
commit,
|
||||
author: "Blame Author".into(),
|
||||
summary: "Original summary".into(),
|
||||
uncommitted: false
|
||||
}
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn unsaved_changed_line_is_uncommitted_without_metadata() {
|
||||
let (_temp, path, _) = fixture();
|
||||
let rows = read_file(&path, "changed\nsecond\n", &|| false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
**rows[0].as_ref().unwrap(),
|
||||
LineBlame {
|
||||
commit: String::new(),
|
||||
author: String::new(),
|
||||
summary: String::new(),
|
||||
uncommitted: true
|
||||
}
|
||||
);
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), "first\nsecond\n");
|
||||
}
|
||||
#[test]
|
||||
fn inserted_line_preserves_following_original_attribution() {
|
||||
let (_temp, path, commit) = fixture();
|
||||
let contents = "inserted\nfirst\nsecond\n";
|
||||
let rows = read_file(&path, contents, &|| false).unwrap().unwrap();
|
||||
assert!(rows[0].as_ref().unwrap().uncommitted);
|
||||
assert_eq!(rows[2].as_ref().unwrap().commit, commit);
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!(read_file(&path, "", &|| false).unwrap(), None);
|
||||
}
|
||||
#[test]
|
||||
fn unsaved_insertions_and_deletions_keep_shifted_commit_attribution() {
|
||||
let (temp, path, original) = fixture();
|
||||
fs::write(&path, "first\nmiddle\nsecond\ntail\n").unwrap();
|
||||
command(&temp.0, "git", &["add", "--", "-literal [file].txt"]);
|
||||
command(&temp.0, "git", &["commit", "--quiet", "-m", "Added lines"]);
|
||||
let added = command(&temp.0, "git", &["rev-parse", "HEAD"]);
|
||||
let rows = read_file(
|
||||
&path,
|
||||
"unsaved\nfirst\nsecond\nunsaved tail\ntail\n",
|
||||
&|| false,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(rows[0].as_ref().unwrap().uncommitted);
|
||||
assert_eq!(rows[1].as_ref().unwrap().commit, original);
|
||||
assert_eq!(rows[2].as_ref().unwrap().commit, original);
|
||||
assert!(Arc::ptr_eq(
|
||||
rows[1].as_ref().unwrap(),
|
||||
rows[2].as_ref().unwrap()
|
||||
));
|
||||
assert!(rows[3].as_ref().unwrap().uncommitted);
|
||||
assert!(Arc::ptr_eq(
|
||||
rows[0].as_ref().unwrap(),
|
||||
rows[3].as_ref().unwrap()
|
||||
));
|
||||
assert_eq!(rows[4].as_ref().unwrap().commit, added);
|
||||
assert_eq!(rows[4].as_ref().unwrap().summary, "Added lines");
|
||||
assert_eq!(
|
||||
fs::read_to_string(path).unwrap(),
|
||||
"first\nmiddle\nsecond\ntail\n"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn missing_untracked_unborn_nonrepo_and_symlink_have_no_blame() {
|
||||
let temp = Temporary::new().unwrap();
|
||||
let path = temp.0.join("file");
|
||||
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
|
||||
fs::write(&path, "text").unwrap();
|
||||
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
|
||||
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||
assert_eq!(read_file(&path, "text", &|| false).unwrap(), None);
|
||||
let (_committed, tracked, _) = fixture();
|
||||
let untracked = tracked.parent().unwrap().join("untracked");
|
||||
fs::write(&untracked, "text").unwrap();
|
||||
assert_eq!(read_file(&untracked, "text", &|| false).unwrap(), None);
|
||||
let link = temp.0.join("link");
|
||||
std::os::unix::fs::symlink(tracked, &link).unwrap();
|
||||
assert_eq!(
|
||||
read_file(&link, "first\nsecond\n", &|| false).unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() {
|
||||
let temp = Temporary::new().unwrap();
|
||||
command(&temp.0, "jj", &["git", "init", "--no-colocate", "."]);
|
||||
let path = temp.0.join("file");
|
||||
fs::write(&path, "original\n").unwrap();
|
||||
assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None);
|
||||
command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--config=user.name=\"Jj Author\"",
|
||||
"--config=user.email=\"jj@example.test\"",
|
||||
"describe",
|
||||
"-m",
|
||||
"Jj summary",
|
||||
],
|
||||
);
|
||||
command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--config=user.name=\"Jj Author\"",
|
||||
"--config=user.email=\"jj@example.test\"",
|
||||
"new",
|
||||
],
|
||||
);
|
||||
let parent = command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-r",
|
||||
"@-",
|
||||
"-T",
|
||||
"commit_id",
|
||||
],
|
||||
);
|
||||
let operation = command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id",
|
||||
],
|
||||
);
|
||||
let rows = read_file(&path, "original\n", &|| false).unwrap().unwrap();
|
||||
let line = rows[0].as_ref().unwrap();
|
||||
assert_eq!(line.commit, parent);
|
||||
assert_eq!(line.author, "Jj Author");
|
||||
assert_eq!(line.summary, "Jj summary");
|
||||
fs::write(&path, "dirty on disk\n").unwrap();
|
||||
let shifted = read_file(&path, "inserted\noriginal\n", &|| false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(shifted[0].as_ref().unwrap().uncommitted);
|
||||
assert_eq!(shifted[1].as_ref().unwrap().commit, parent);
|
||||
assert!(
|
||||
read_file(&path, "unsaved\n", &|| false).unwrap().unwrap()[0]
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.uncommitted
|
||||
);
|
||||
assert_eq!(
|
||||
command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id"
|
||||
]
|
||||
),
|
||||
operation
|
||||
);
|
||||
assert!(!temp.0.join(".git").exists());
|
||||
command(&temp.0, "jj", &["new", "root()"]);
|
||||
fs::write(temp.0.join("other"), "sibling\n").unwrap();
|
||||
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
|
||||
command(&temp.0, "jj", &["new", &parent, "@"]);
|
||||
assert_eq!(read_file(&path, "original\n", &|| false).unwrap(), None);
|
||||
}
|
||||
#[test]
|
||||
fn incremental_groups_share_metadata_across_ranges_and_repeated_commits() {
|
||||
let first = "1111111111111111111111111111111111111111";
|
||||
let second = "2222222222222222222222222222222222222222";
|
||||
let output = format!(
|
||||
"{first} 5 3 2\nauthor First\nsummary First summary\nfilename file\n\
|
||||
{second} 8 2 1\nauthor Second\nsummary Second summary\nfilename file\n\
|
||||
{first} 1 1 1\nfilename old-file\n"
|
||||
);
|
||||
let rows = parse(output.as_bytes(), 4).unwrap();
|
||||
assert_eq!(rows[0].as_ref().unwrap().commit, first);
|
||||
assert_eq!(rows[1].as_ref().unwrap().author, "Second");
|
||||
assert_eq!(rows[1].as_ref().unwrap().commit, second);
|
||||
assert!(Arc::ptr_eq(
|
||||
rows[0].as_ref().unwrap(),
|
||||
rows[2].as_ref().unwrap()
|
||||
));
|
||||
assert!(Arc::ptr_eq(
|
||||
rows[2].as_ref().unwrap(),
|
||||
rows[3].as_ref().unwrap()
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn malformed_incremental_groups_never_echo_metadata() {
|
||||
let commit = "1111111111111111111111111111111111111111";
|
||||
let metadata = "author secret\nsummary secret\nfilename secret\n";
|
||||
let valid = format!("{commit} 1 1 1\n{metadata}");
|
||||
for output in [
|
||||
"secret metadata".into(),
|
||||
"\u{fffd}secret metadata".into(),
|
||||
format!("{commit} 1 2 1\n{metadata}"),
|
||||
format!("{commit} 1 1 2\n{metadata}"),
|
||||
format!("{commit} 0 1 1\n{metadata}"),
|
||||
format!("{commit} 1 1 0\n{metadata}"),
|
||||
format!("{commit} 1 1 1 extra\n{metadata}"),
|
||||
format!("{commit} 1 1 1\nauthor secret\nfilename secret\n"),
|
||||
format!(
|
||||
"{commit} 1 1 1\nauthor secret\nauthor secret\nsummary secret\nfilename secret\n"
|
||||
),
|
||||
format!("{commit} 1 1 1\nauthor secret\nsummary secret\n\tsecret source\n"),
|
||||
format!("{valid}{commit} 2 1 1\nfilename secret\n"),
|
||||
format!(
|
||||
"{commit} 1 1 1\nauthor secret\nsummary secret\n{commit} 2 1 1\nfilename secret\n"
|
||||
),
|
||||
format!("{commit} {} 1 2\n{metadata}", usize::MAX),
|
||||
] {
|
||||
assert_eq!(
|
||||
parse(output.as_bytes(), 1).unwrap_err(),
|
||||
"Invalid Git blame metadata"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
parse(valid.as_bytes(), 2).unwrap_err(),
|
||||
"Invalid Git blame metadata"
|
||||
);
|
||||
assert_eq!(
|
||||
parse(b"\xffsecret metadata", 1).unwrap_err(),
|
||||
"Invalid Git blame metadata"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn cancelled_commands_kill_and_reap_pipe_holding_children() {
|
||||
let temp = Temporary::new().unwrap();
|
||||
let ready = temp.0.join("ready");
|
||||
let cancellation = Arc::new(AtomicBool::new(false));
|
||||
let stop = Arc::clone(&cancellation);
|
||||
let gate = ready.clone();
|
||||
let worker = thread::spawn(move || {
|
||||
let mut command = Command::new("sh");
|
||||
command
|
||||
.args([
|
||||
"-c",
|
||||
"sleep 600 & printf ready > \"$1\"; wait",
|
||||
"vamp-blame-test",
|
||||
])
|
||||
.arg(gate);
|
||||
run(command, &[], &|| stop.load(Ordering::Acquire))
|
||||
});
|
||||
while !ready.exists() && !worker.is_finished() {
|
||||
thread::yield_now();
|
||||
}
|
||||
assert!(ready.exists());
|
||||
cancellation.store(true, Ordering::Release);
|
||||
assert_eq!(worker.join().unwrap().unwrap_err(), "Blame cancelled");
|
||||
}
|
||||
#[test]
|
||||
fn worker_keeps_only_latest_request_and_bounds_input() {
|
||||
let (_temp, path, commit) = fixture();
|
||||
let mut worker = BlameWorker::start().unwrap();
|
||||
for id in 0..20 {
|
||||
worker
|
||||
.request(id, path.clone(), "first\nsecond\n".into())
|
||||
.unwrap();
|
||||
}
|
||||
loop {
|
||||
if let Some((id, result)) = worker.poll() {
|
||||
assert_eq!(id, 19);
|
||||
let rows = result.unwrap().unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[1].as_ref().unwrap().commit, commit);
|
||||
assert!(Arc::ptr_eq(
|
||||
rows[0].as_ref().unwrap(),
|
||||
rows[1].as_ref().unwrap()
|
||||
));
|
||||
break;
|
||||
}
|
||||
thread::yield_now();
|
||||
}
|
||||
assert_eq!(
|
||||
worker
|
||||
.request(20, path, "x".repeat(LIMIT + 1))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::InvalidInput
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn subprocess_output_is_bounded() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "dd if=/dev/zero bs=1048576 count=17 2>/dev/null"]);
|
||||
assert_eq!(
|
||||
run(command, &[], &|| false).unwrap_err(),
|
||||
"Blame output exceeds 16 MiB"
|
||||
);
|
||||
}
|
||||
}
|
||||
+36
-26
@@ -106,6 +106,7 @@ pub struct ChromeLine {
|
||||
pub struct SegmentConfig {
|
||||
pub title: String,
|
||||
pub padding: usize,
|
||||
pub margin: [usize; 2],
|
||||
pub leading_edge: String,
|
||||
pub trailing_edge: String,
|
||||
pub first_leading_edge: Option<String>,
|
||||
@@ -116,6 +117,7 @@ impl Default for SegmentConfig {
|
||||
Self {
|
||||
title: String::new(),
|
||||
padding: 1,
|
||||
margin: [0; 2],
|
||||
leading_edge: String::new(),
|
||||
trailing_edge: String::new(),
|
||||
first_leading_edge: None,
|
||||
@@ -146,7 +148,13 @@ impl ChromeLine {
|
||||
right: vec!["language".into(), "lsp".into(), "position".into()],
|
||||
segments: DEFAULT_BOTTOM_SEGMENTS
|
||||
.iter()
|
||||
.map(|name| ((*name).into(), default_segment(name)))
|
||||
.map(|name| {
|
||||
let mut segment = default_segment(name);
|
||||
if *name == "position" {
|
||||
segment.margin[0] = 1;
|
||||
}
|
||||
((*name).into(), segment)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
@@ -167,10 +175,16 @@ impl ChromeLine {
|
||||
validate_name(name)?;
|
||||
}
|
||||
for (name, segment) in &self.segments {
|
||||
if segment.padding > 64 {
|
||||
return Err(format!(
|
||||
"Chrome segment {name:?} padding must be between 0 and 64"
|
||||
));
|
||||
for (option, value) in [
|
||||
("padding", segment.padding),
|
||||
("leading_margin", segment.margin[0]),
|
||||
("trailing_margin", segment.margin[1]),
|
||||
] {
|
||||
if value > 64 {
|
||||
return Err(format!(
|
||||
"Chrome segment {name:?} {option} must be between 0 and 64"
|
||||
));
|
||||
}
|
||||
}
|
||||
for (option, text) in [
|
||||
("title", Some(&segment.title)),
|
||||
@@ -258,6 +272,9 @@ struct LineOptions {
|
||||
struct SegmentOptions {
|
||||
title: Option<String>,
|
||||
padding: Option<usize>,
|
||||
margin: Option<usize>,
|
||||
leading_margin: Option<usize>,
|
||||
trailing_margin: Option<usize>,
|
||||
leading_edge: Option<String>,
|
||||
trailing_edge: Option<String>,
|
||||
first_leading_edge: Option<String>,
|
||||
@@ -304,6 +321,15 @@ fn deserialize_line<'de, D: Deserializer<'de>>(
|
||||
if let Some(padding) = options.padding {
|
||||
segment.padding = padding;
|
||||
}
|
||||
if let Some(margin) = options.margin {
|
||||
segment.margin = [margin; 2];
|
||||
}
|
||||
if let Some(margin) = options.leading_margin {
|
||||
segment.margin[0] = margin;
|
||||
}
|
||||
if let Some(margin) = options.trailing_margin {
|
||||
segment.margin[1] = margin;
|
||||
}
|
||||
if let Some(edge) = options.leading_edge {
|
||||
segment.leading_edge = edge;
|
||||
}
|
||||
@@ -402,27 +428,6 @@ mod tests {
|
||||
bottom: ChromeLine,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_keeps_metadata_on_the_right_without_filled_styles() {
|
||||
let line = ChromeLine::bottom();
|
||||
assert_eq!(
|
||||
line.left,
|
||||
[
|
||||
"mode",
|
||||
"pending",
|
||||
"buffer",
|
||||
"diagnostics",
|
||||
"path",
|
||||
"selections",
|
||||
"count"
|
||||
]
|
||||
);
|
||||
assert_eq!(line.right, ["language", "lsp", "position"]);
|
||||
for name in ["language", "lsp"] {
|
||||
assert_eq!(line.segment(name), &SegmentConfig::default());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_options_inherit_and_explicit_groups_replace_defaults() {
|
||||
let lines: Lines = serde_json::from_value(serde_json::json!({
|
||||
@@ -521,6 +526,11 @@ mod tests {
|
||||
serde_json::json!({"left": null}),
|
||||
serde_json::json!({"tabs": {"padding": 65}}),
|
||||
serde_json::json!({"tabs": {"padding": null}}),
|
||||
serde_json::json!({"tabs": {"margin": 65}}),
|
||||
serde_json::json!({"tabs": {"margin": -1}}),
|
||||
serde_json::json!({"tabs": {"margin": null}}),
|
||||
serde_json::json!({"tabs": {"margin": "1"}}),
|
||||
serde_json::json!({"tabs": {"margin": 1.5}}),
|
||||
serde_json::json!({"tabs": {"leading_edge": "\n"}}),
|
||||
serde_json::json!({"tabs": {"title": "{unknown}"}}),
|
||||
serde_json::json!({"tabs": {"title": "{name"}}),
|
||||
|
||||
+190
-36
@@ -15,20 +15,6 @@ function run() {
|
||||
}
|
||||
"#;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const MAC_WRITE: &str = r#"
|
||||
ObjC.import('AppKit');
|
||||
function run() {
|
||||
var data = $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile;
|
||||
var text = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding);
|
||||
if (text.isNil()) throw Error('Clipboard input is not UTF-8');
|
||||
var pasteboard = $.NSPasteboard.generalPasteboard;
|
||||
pasteboard.clearContents;
|
||||
if (!pasteboard.setStringForType(text, $.NSPasteboardTypeString))
|
||||
throw Error('Cannot write plain-text clipboard');
|
||||
}
|
||||
"#;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ClipboardMode {
|
||||
System,
|
||||
@@ -42,7 +28,7 @@ struct ClipboardCommand {
|
||||
|
||||
struct Commands {
|
||||
reader: ClipboardCommand,
|
||||
writer: ClipboardCommand,
|
||||
writer: Option<ClipboardCommand>,
|
||||
}
|
||||
|
||||
pub struct SystemClipboard {
|
||||
@@ -72,10 +58,10 @@ impl SystemClipboard {
|
||||
program: reader,
|
||||
args: &[],
|
||||
},
|
||||
writer: ClipboardCommand {
|
||||
writer: Some(ClipboardCommand {
|
||||
program: writer,
|
||||
args: &[],
|
||||
},
|
||||
}),
|
||||
})),
|
||||
timeout: COMMAND_TIMEOUT,
|
||||
}
|
||||
@@ -95,8 +81,13 @@ impl SystemClipboard {
|
||||
}
|
||||
let timeout = self.timeout;
|
||||
let commands = self.commands.get_or_insert_with(select_commands);
|
||||
let command = &commands.as_ref().map_err(Clone::clone)?.writer;
|
||||
run(command, Some(text.as_bytes()), timeout).map(|_| ())
|
||||
match &commands.as_ref().map_err(Clone::clone)?.writer {
|
||||
Some(command) => run(command, Some(text.as_bytes()), timeout).map(|_| ()),
|
||||
#[cfg(target_os = "macos")]
|
||||
None => macos::write(text),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
None => Err("System clipboard is unsupported on this platform".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,19 +108,13 @@ fn executable(name: &str) -> Option<PathBuf> {
|
||||
fn select_commands() -> Result<Commands, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(program) = executable("osascript") {
|
||||
return Ok(Commands {
|
||||
reader: ClipboardCommand {
|
||||
program: program.clone(),
|
||||
args: &["-l", "JavaScript", "-e", MAC_READ],
|
||||
},
|
||||
writer: ClipboardCommand {
|
||||
program,
|
||||
args: &["-l", "JavaScript", "-e", MAC_WRITE],
|
||||
},
|
||||
});
|
||||
}
|
||||
Err("System clipboard requires osascript with JXA/AppKit on PATH".into())
|
||||
Ok(Commands {
|
||||
reader: ClipboardCommand {
|
||||
program: executable("osascript").unwrap_or_else(|| "/usr/bin/osascript".into()),
|
||||
args: &["-l", "JavaScript", "-e", MAC_READ],
|
||||
},
|
||||
writer: None,
|
||||
})
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
@@ -140,10 +125,10 @@ fn select_commands() -> Result<Commands, String> {
|
||||
program: reader,
|
||||
args: &["--no-newline", "--type", "text"],
|
||||
},
|
||||
writer: ClipboardCommand {
|
||||
writer: Some(ClipboardCommand {
|
||||
program: writer,
|
||||
args: &["--type", "text/plain;charset=utf-8"],
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -154,10 +139,10 @@ fn select_commands() -> Result<Commands, String> {
|
||||
program: program.clone(),
|
||||
args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-out"],
|
||||
},
|
||||
writer: ClipboardCommand {
|
||||
writer: Some(ClipboardCommand {
|
||||
program,
|
||||
args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-in"],
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -169,6 +154,173 @@ fn select_commands() -> Result<Commands, String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos {
|
||||
use std::ffi::{c_char, c_void};
|
||||
use std::ptr;
|
||||
|
||||
type Object = *const c_void;
|
||||
|
||||
#[link(name = "CoreFoundation", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn CFStringCreateWithCString(
|
||||
allocator: Object,
|
||||
bytes: *const c_char,
|
||||
encoding: u32,
|
||||
) -> Object;
|
||||
fn CFDataCreate(allocator: Object, bytes: *const u8, length: isize) -> Object;
|
||||
fn CFRelease(object: Object);
|
||||
}
|
||||
|
||||
#[link(name = "CoreServices", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
static kUTTypeUTF8PlainText: Object;
|
||||
}
|
||||
|
||||
#[link(name = "ApplicationServices", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn PasteboardCreate(name: Object, pasteboard: *mut Object) -> i32;
|
||||
fn PasteboardClear(pasteboard: Object) -> i32;
|
||||
fn PasteboardPutItemFlavor(
|
||||
pasteboard: Object,
|
||||
item: usize,
|
||||
flavor: Object,
|
||||
data: Object,
|
||||
flags: u32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
struct Owned(Object);
|
||||
impl Owned {
|
||||
fn new(object: Object) -> Result<Self, String> {
|
||||
if object.is_null() {
|
||||
Err("Cannot allocate system clipboard data".into())
|
||||
} else {
|
||||
Ok(Self(object))
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Owned {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: each handle owns one Core Foundation reference.
|
||||
unsafe { CFRelease(self.0) };
|
||||
}
|
||||
}
|
||||
|
||||
fn check(status: i32) -> Result<(), String> {
|
||||
if status == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("System clipboard publication failed ({status})"))
|
||||
}
|
||||
}
|
||||
|
||||
fn write_to(name: Object, text: &str) -> Result<(), String> {
|
||||
// SAFETY: framework constants and byte slices live throughout the calls.
|
||||
unsafe {
|
||||
let data = Owned::new(CFDataCreate(
|
||||
ptr::null(),
|
||||
text.as_ptr(),
|
||||
text.len() as isize,
|
||||
))?;
|
||||
let mut board = ptr::null();
|
||||
check(PasteboardCreate(name, &mut board))?;
|
||||
let board = Owned::new(board)?;
|
||||
// Prepare all allocations before changing clipboard contents.
|
||||
check(PasteboardClear(board.0))?;
|
||||
check(PasteboardPutItemFlavor(
|
||||
board.0,
|
||||
1,
|
||||
kUTTypeUTF8PlainText,
|
||||
data.0,
|
||||
0,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn write(text: &str) -> Result<(), String> {
|
||||
// SAFETY: the clipboard name is a process-lifetime, null-terminated literal.
|
||||
let name = unsafe {
|
||||
Owned::new(CFStringCreateWithCString(
|
||||
ptr::null(),
|
||||
c"com.apple.pasteboard.clipboard".as_ptr(),
|
||||
0x0800_0100,
|
||||
))?
|
||||
};
|
||||
write_to(name.0, text)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
unsafe extern "C" {
|
||||
fn PasteboardSynchronize(pasteboard: Object) -> u32;
|
||||
fn PasteboardGetItemIdentifier(
|
||||
pasteboard: Object,
|
||||
index: isize,
|
||||
item: *mut usize,
|
||||
) -> i32;
|
||||
fn PasteboardCopyItemFlavorData(
|
||||
pasteboard: Object,
|
||||
item: usize,
|
||||
flavor: Object,
|
||||
data: *mut Object,
|
||||
) -> i32;
|
||||
fn CFDataGetBytePtr(data: Object) -> *const u8;
|
||||
fn CFDataGetLength(data: Object) -> isize;
|
||||
}
|
||||
#[test]
|
||||
fn native_publication_preserves_plain_utf8_bytes_on_an_isolated_pasteboard() {
|
||||
// A private pasteboard leaves the user's clipboard untouched.
|
||||
let name = unsafe {
|
||||
Owned::new(CFStringCreateWithCString(
|
||||
ptr::null(),
|
||||
c"vamp-native-clipboard-regression".as_ptr(),
|
||||
0x0800_0100,
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let board = unsafe {
|
||||
let mut board = ptr::null();
|
||||
check(PasteboardCreate(name.0, &mut board)).unwrap();
|
||||
Owned::new(board).unwrap()
|
||||
};
|
||||
for text in [
|
||||
"",
|
||||
"é e\u{301} 👨💻\n\0tail",
|
||||
"{\\rtf1 plain text}",
|
||||
"%!PS-Adobe-3.0\nplain",
|
||||
] {
|
||||
write_to(name.0, text).unwrap();
|
||||
unsafe {
|
||||
PasteboardSynchronize(board.0);
|
||||
let mut item = 0;
|
||||
check(PasteboardGetItemIdentifier(board.0, 1, &mut item)).unwrap();
|
||||
let flavor = Owned::new(CFStringCreateWithCString(
|
||||
ptr::null(),
|
||||
c"public.utf8-plain-text".as_ptr(),
|
||||
0x0800_0100,
|
||||
))
|
||||
.unwrap();
|
||||
let mut data = ptr::null();
|
||||
check(PasteboardCopyItemFlavorData(
|
||||
board.0, item, flavor.0, &mut data,
|
||||
))
|
||||
.unwrap();
|
||||
let data = Owned::new(data).unwrap();
|
||||
let length = CFDataGetLength(data.0) as usize;
|
||||
let bytes = if length == 0 {
|
||||
&[]
|
||||
} else {
|
||||
std::slice::from_raw_parts(CFDataGetBytePtr(data.0), length)
|
||||
};
|
||||
assert_eq!(bytes, text.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
fn run(_: &ClipboardCommand, _: Option<&[u8]>, _: Duration) -> Result<Vec<u8>, String> {
|
||||
Err("System clipboard is unsupported on this platform".into())
|
||||
@@ -514,6 +666,8 @@ mod tests {
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.writer
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.program = fixture.command(
|
||||
"noisy",
|
||||
&format!("/bin/dd if=/dev/zero bs=65536 count=8 >&2\nexec /bin/cat > {state}"),
|
||||
|
||||
+683
-28
@@ -39,6 +39,20 @@ impl RepoKind {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum ComparisonMode {
|
||||
#[default]
|
||||
BranchChanges,
|
||||
Snapshots,
|
||||
}
|
||||
impl ComparisonMode {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::BranchChanges => "Branch changes",
|
||||
Self::Snapshots => "Snapshots",
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum RevisionTarget {
|
||||
Working,
|
||||
@@ -105,6 +119,11 @@ pub enum Operation {
|
||||
Compare {
|
||||
before: Revision,
|
||||
after: Revision,
|
||||
mode: ComparisonMode,
|
||||
buffers: Box<[(PathBuf, String)]>,
|
||||
},
|
||||
RefreshWorking {
|
||||
comparison_id: u64,
|
||||
buffers: Box<[(PathBuf, String)]>,
|
||||
},
|
||||
LoadFile {
|
||||
@@ -116,6 +135,7 @@ pub enum Operation {
|
||||
pub enum Outcome {
|
||||
Catalog(Catalog),
|
||||
Comparison(Comparison),
|
||||
WorkingRefresh(Option<Comparison>),
|
||||
File {
|
||||
comparison_id: u64,
|
||||
file_index: usize,
|
||||
@@ -175,6 +195,9 @@ pub enum DisplayRow {
|
||||
enum RequestKind {
|
||||
Catalog,
|
||||
Comparison,
|
||||
WorkingRefresh {
|
||||
comparison_id: u64,
|
||||
},
|
||||
File {
|
||||
comparison_id: u64,
|
||||
file_index: usize,
|
||||
@@ -187,6 +210,7 @@ pub struct DiffState {
|
||||
pub phase: Phase,
|
||||
pub word_wrap: bool,
|
||||
pub view_mode: ViewMode,
|
||||
pub comparison_mode: ComparisonMode,
|
||||
pub split_axis: crate::panes::SplitAxis,
|
||||
pub before: Option<Revision>,
|
||||
pub after: Option<Revision>,
|
||||
@@ -197,6 +221,7 @@ pub struct DiffState {
|
||||
pub error: Option<String>,
|
||||
pub comparison: Option<Comparison>,
|
||||
pub comparison_id: Option<u64>,
|
||||
pub reviewed_files: BTreeSet<usize>,
|
||||
pub(crate) snapshot_roots: [Option<PathBuf>; 2],
|
||||
pub files: Option<FilePicker>,
|
||||
pub files_visible: bool,
|
||||
@@ -218,7 +243,8 @@ pub struct DiffState {
|
||||
pending: Option<Operation>,
|
||||
request_kind: Option<RequestKind>,
|
||||
visible: Vec<usize>,
|
||||
search_labels: Vec<String>,
|
||||
search_fields: Vec<[String; 3]>,
|
||||
search_matches: Vec<([usize; 7], usize)>,
|
||||
max_before: usize,
|
||||
max_after: usize,
|
||||
after_widths: Vec<usize>,
|
||||
@@ -231,6 +257,7 @@ impl DiffState {
|
||||
phase: Phase::Before,
|
||||
word_wrap: false,
|
||||
view_mode: ViewMode::Split,
|
||||
comparison_mode: ComparisonMode::default(),
|
||||
split_axis: crate::panes::SplitAxis::Vertical,
|
||||
before: None,
|
||||
after: None,
|
||||
@@ -241,6 +268,7 @@ impl DiffState {
|
||||
error: None,
|
||||
comparison: None,
|
||||
comparison_id: None,
|
||||
reviewed_files: BTreeSet::new(),
|
||||
files: None,
|
||||
snapshot_roots: [None, None],
|
||||
files_visible: true,
|
||||
@@ -262,7 +290,8 @@ impl DiffState {
|
||||
pending: Some(Operation::Discover),
|
||||
request_kind: Some(RequestKind::Catalog),
|
||||
visible: Vec::new(),
|
||||
search_labels: Vec::new(),
|
||||
search_fields: Vec::new(),
|
||||
search_matches: Vec::new(),
|
||||
max_before: 0,
|
||||
max_after: 0,
|
||||
after_widths: Vec::new(),
|
||||
@@ -276,6 +305,9 @@ impl DiffState {
|
||||
}
|
||||
|
||||
pub(crate) fn take_request(&mut self) -> Option<Operation> {
|
||||
if !self.request_ready() {
|
||||
return None;
|
||||
}
|
||||
self.pending.take()
|
||||
}
|
||||
|
||||
@@ -283,7 +315,49 @@ impl DiffState {
|
||||
self.pending.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn request_ready(&self) -> bool {
|
||||
self.pending.as_ref().is_some_and(|operation| {
|
||||
!self.refresh_pending() || matches!(operation, Operation::RefreshWorking { .. })
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_pending(&self) -> bool {
|
||||
matches!(self.request_kind, Some(RequestKind::WorkingRefresh { .. }))
|
||||
}
|
||||
|
||||
pub(crate) fn queue_working_refresh(&mut self) {
|
||||
if self.phase != Phase::View
|
||||
|| self.loading
|
||||
|| self.request_kind.is_some()
|
||||
|| self.pending.is_some()
|
||||
|| self.comparison.is_none()
|
||||
|| !self
|
||||
.after
|
||||
.as_ref()
|
||||
.is_some_and(|after| after.target == RevisionTarget::Working)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(comparison_id) = self.comparison_id {
|
||||
self.request_kind = Some(RequestKind::WorkingRefresh { comparison_id });
|
||||
self.pending = Some(Operation::RefreshWorking {
|
||||
comparison_id,
|
||||
buffers: Box::new([]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn queue(&mut self, operation: Operation) {
|
||||
if self.refresh_pending()
|
||||
&& !matches!(self.pending, Some(Operation::RefreshWorking { .. }))
|
||||
&& matches!(operation, Operation::LoadFile { .. })
|
||||
{
|
||||
// Finish the refresh before issuing a load against its frozen comparison ID.
|
||||
self.pending = Some(operation);
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
return;
|
||||
}
|
||||
self.request_kind = Some(match operation {
|
||||
Operation::Compare { .. } => RequestKind::Comparison,
|
||||
Operation::LoadFile {
|
||||
@@ -300,9 +374,66 @@ impl DiffState {
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
pub(crate) fn apply_result(&mut self, id: u64, result: Result<Outcome, String>) {
|
||||
pub(crate) fn apply_result(&mut self, id: u64, result: Result<Outcome, String>) -> bool {
|
||||
if self.request_kind.is_none() {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if let Some(RequestKind::WorkingRefresh { comparison_id }) = self.request_kind {
|
||||
if self.comparison_id != Some(comparison_id) {
|
||||
return false;
|
||||
}
|
||||
self.request_kind = None;
|
||||
let deferred = self.pending.take();
|
||||
match result {
|
||||
Ok(Outcome::WorkingRefresh(None)) => {
|
||||
if let Some(operation) = deferred {
|
||||
self.queue(operation);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Ok(Outcome::WorkingRefresh(Some(comparison))) => {
|
||||
let selected = self
|
||||
.comparison
|
||||
.as_ref()
|
||||
.and_then(|comparison| comparison.files.get(self.file))
|
||||
.map(|file| &file.path);
|
||||
self.file = selected
|
||||
.and_then(|path| {
|
||||
comparison.files.iter().position(|file| &file.path == path)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if let Some(files) = &mut self.files {
|
||||
files.replace_paths(
|
||||
comparison
|
||||
.files
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, file)| (index, file.path.clone())),
|
||||
);
|
||||
}
|
||||
self.comparison = Some(comparison);
|
||||
self.comparison_id = Some(id);
|
||||
self.snapshot_roots = [None, None];
|
||||
self.reviewed_files.clear();
|
||||
self.live_files.clear();
|
||||
self.live_buffer = None;
|
||||
self.live_revision = None;
|
||||
self.follow_caret = false;
|
||||
self.prepare_selected_file();
|
||||
self.normalize_focus();
|
||||
}
|
||||
Err(error) => {
|
||||
if let Some(operation) = deferred {
|
||||
self.queue(operation);
|
||||
}
|
||||
self.error = Some(error);
|
||||
}
|
||||
Ok(_) => self.error = Some("Unexpected repository response".into()),
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if matches!(result, Ok(Outcome::WorkingRefresh(_))) {
|
||||
return false;
|
||||
}
|
||||
if let Ok(Outcome::File {
|
||||
comparison_id,
|
||||
@@ -316,7 +447,7 @@ impl DiffState {
|
||||
file_index: *file_index,
|
||||
}))
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let expected = self.request_kind.take();
|
||||
self.loading = false;
|
||||
@@ -327,7 +458,7 @@ impl DiffState {
|
||||
.entries()
|
||||
.nth(self.selected)
|
||||
.map(|entry| entry.target.clone());
|
||||
self.search_labels = catalog
|
||||
self.search_fields = catalog
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
@@ -335,7 +466,11 @@ impl DiffState {
|
||||
RevisionTarget::Commit(id) => id.as_str(),
|
||||
_ => "",
|
||||
};
|
||||
format!("{} {} {id}", entry.label, entry.description).to_lowercase()
|
||||
[
|
||||
entry.label.to_lowercase(),
|
||||
id.to_lowercase(),
|
||||
entry.description.to_lowercase(),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
self.category = self.category.min(catalog.kind.categories().len() - 1);
|
||||
@@ -348,6 +483,7 @@ impl DiffState {
|
||||
}
|
||||
Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => {
|
||||
self.live_files.clear();
|
||||
self.reviewed_files.clear();
|
||||
self.live_buffer = None;
|
||||
self.live_revision = None;
|
||||
self.follow_caret = false;
|
||||
@@ -410,10 +546,12 @@ impl DiffState {
|
||||
Ok(_) => self.error = Some("Unexpected repository response".into()),
|
||||
Err(error) => self.error = Some(error),
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn refilter(&mut self) {
|
||||
self.visible.clear();
|
||||
self.search_matches.clear();
|
||||
if let Some(catalog) = &self.catalog {
|
||||
let category = catalog.kind.categories()[self.category];
|
||||
let query = self.query.to_lowercase();
|
||||
@@ -427,20 +565,47 @@ impl DiffState {
|
||||
.filter(|(_, entry)| entry.target == RevisionTarget::Working)
|
||||
.map(|(index, _)| index),
|
||||
);
|
||||
self.visible.extend(
|
||||
catalog
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(index, entry)| {
|
||||
entry.target != RevisionTarget::Working
|
||||
&& entry.category == category
|
||||
&& tokens
|
||||
.iter()
|
||||
.all(|token| self.search_labels[*index].contains(token))
|
||||
})
|
||||
.map(|(index, _)| index),
|
||||
);
|
||||
for (index, entry) in catalog.entries.iter().enumerate() {
|
||||
if entry.target == RevisionTarget::Working || entry.category != category {
|
||||
continue;
|
||||
}
|
||||
if tokens.is_empty() {
|
||||
self.visible.push(index);
|
||||
continue;
|
||||
}
|
||||
let score = tokens.iter().try_fold([0usize; 7], |mut score, token| {
|
||||
let best = self.search_fields[index]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(field_index, field)| {
|
||||
revision_match(field, token).map(
|
||||
|[kind, boundaries, gaps, start, len]| {
|
||||
[
|
||||
kind,
|
||||
usize::from(field_index == 2),
|
||||
boundaries,
|
||||
gaps,
|
||||
start,
|
||||
len,
|
||||
]
|
||||
},
|
||||
)
|
||||
})
|
||||
.min()?;
|
||||
// A scattered token never outranks an all-contiguous match.
|
||||
score[0] = score[0].max(best[0]);
|
||||
for (total, value) in score[1..].iter_mut().zip(best) {
|
||||
*total += value;
|
||||
}
|
||||
Some(score)
|
||||
});
|
||||
if let Some(score) = score {
|
||||
self.search_matches.push((score, index));
|
||||
}
|
||||
}
|
||||
self.search_matches.sort_unstable();
|
||||
self.visible
|
||||
.extend(self.search_matches.iter().map(|(_, index)| *index));
|
||||
}
|
||||
self.selected = 0;
|
||||
}
|
||||
@@ -548,6 +713,9 @@ impl DiffState {
|
||||
}
|
||||
return;
|
||||
};
|
||||
if revision.target == RevisionTarget::Working {
|
||||
self.comparison_mode = ComparisonMode::Snapshots;
|
||||
}
|
||||
if let RevisionTarget::FetchRemote(remote) = &revision.target {
|
||||
self.queue(Operation::FetchRemote(remote.clone()));
|
||||
} else if let RevisionTarget::ListRemote(remote) = &revision.target {
|
||||
@@ -559,13 +727,44 @@ impl DiffState {
|
||||
self.query.clear();
|
||||
self.refilter();
|
||||
self.error = None;
|
||||
} else if let Some(before) = &self.before {
|
||||
self.after = Some(revision.clone());
|
||||
self.queue(Operation::Compare {
|
||||
} else {
|
||||
self.after = Some(revision);
|
||||
self.queue_comparison();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn working_selected(&self) -> bool {
|
||||
[&self.before, &self.after]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|revision| revision.target == RevisionTarget::Working)
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_comparison_mode(&mut self) -> Result<(), &'static str> {
|
||||
if self.working_selected() {
|
||||
return Err("Working checkout requires Snapshots");
|
||||
}
|
||||
self.comparison_mode = match self.comparison_mode {
|
||||
ComparisonMode::BranchChanges => ComparisonMode::Snapshots,
|
||||
ComparisonMode::Snapshots => ComparisonMode::BranchChanges,
|
||||
};
|
||||
self.queue_comparison();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn queue_comparison(&mut self) {
|
||||
if let (Some(before), Some(after)) = (&self.before, &self.after) {
|
||||
let operation = Operation::Compare {
|
||||
before: before.clone(),
|
||||
after: revision,
|
||||
after: after.clone(),
|
||||
mode: self.comparison_mode,
|
||||
buffers: Box::new([]),
|
||||
});
|
||||
};
|
||||
self.phase = Phase::After;
|
||||
self.comparison = None;
|
||||
self.comparison_id = None;
|
||||
self.reviewed_files.clear();
|
||||
self.queue(operation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -729,6 +928,15 @@ impl DiffState {
|
||||
self.focus = match (self.focus, self.pane_layout()) {
|
||||
(Focus::Files, _) if self.files_visible => Focus::Files,
|
||||
(Focus::Before | Focus::After, PaneLayout::Split) => self.focus,
|
||||
(Focus::Before | Focus::After, PaneLayout::Empty)
|
||||
if self
|
||||
.comparison
|
||||
.as_ref()
|
||||
.is_some_and(|comparison| !comparison.files.is_empty())
|
||||
&& !self.selected_file_loaded() =>
|
||||
{
|
||||
self.focus
|
||||
}
|
||||
_ => self.first_text_focus(),
|
||||
};
|
||||
if let Some(files) = &mut self.files {
|
||||
@@ -788,6 +996,45 @@ impl DiffState {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_reviewed(&mut self) -> Result<bool, &'static str> {
|
||||
let comparison = self
|
||||
.comparison
|
||||
.as_ref()
|
||||
.filter(|_| self.phase == Phase::View)
|
||||
.ok_or("No comparison is open")?;
|
||||
let index = if self.focus == Focus::Files {
|
||||
let entry = self
|
||||
.files
|
||||
.as_ref()
|
||||
.filter(|_| self.files_visible)
|
||||
.and_then(|files| files.entries().nth(files.selected))
|
||||
.ok_or("No changed file is selected")?;
|
||||
if entry.is_dir {
|
||||
return Err("Select a changed file, not a directory");
|
||||
}
|
||||
entry
|
||||
.catalog_index
|
||||
.filter(|&index| {
|
||||
comparison
|
||||
.files
|
||||
.get(index)
|
||||
.is_some_and(|file| file.path == entry.path)
|
||||
})
|
||||
.ok_or("No changed file is selected")?
|
||||
} else {
|
||||
self.file
|
||||
};
|
||||
if comparison.files.get(index).is_none() {
|
||||
return Err("No changed file is selected");
|
||||
}
|
||||
if self.reviewed_files.remove(&index) {
|
||||
Ok(false)
|
||||
} else {
|
||||
self.reviewed_files.insert(index);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn select_file(&mut self, path: &Path) -> bool {
|
||||
let Some(index) = self.comparison.as_ref().and_then(|comparison| {
|
||||
let relative = path.strip_prefix(&comparison.root).unwrap_or(path);
|
||||
@@ -829,6 +1076,16 @@ impl DiffState {
|
||||
}
|
||||
|
||||
fn prepare_selected_file(&mut self) {
|
||||
if self.refresh_pending() {
|
||||
self.align_file();
|
||||
if !self.selected_file_loaded() {
|
||||
self.load_file(self.file);
|
||||
} else if matches!(self.pending, Some(Operation::LoadFile { .. })) {
|
||||
self.pending = None;
|
||||
self.loading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.pending = None;
|
||||
self.request_kind = None;
|
||||
self.loading = false;
|
||||
@@ -1054,6 +1311,17 @@ impl DiffState {
|
||||
let file = &self.comparison.as_ref().unwrap().files[self.file];
|
||||
let was_live = self.live_files.contains(&self.file);
|
||||
let changed_lines = changed_line_range(&file.after, lines);
|
||||
if self.reviewed_files.contains(&self.file) {
|
||||
let unchanged = if was_live {
|
||||
changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2
|
||||
} else {
|
||||
let eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty);
|
||||
file.after_eof == eof && file.after == live_content(lines, eof)
|
||||
};
|
||||
if !unchanged {
|
||||
self.reviewed_files.remove(&self.file);
|
||||
}
|
||||
}
|
||||
if was_live && changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2 {
|
||||
self.live_revision = Some(revision);
|
||||
return;
|
||||
@@ -1423,6 +1691,60 @@ impl DiffState {
|
||||
}
|
||||
}
|
||||
|
||||
// Match class, missed word/reference boundaries, gaps, start, field length.
|
||||
fn revision_match(field: &str, token: &str) -> Option<[usize; 5]> {
|
||||
if field == token {
|
||||
return Some([0, 0, 0, 0, field.len()]);
|
||||
}
|
||||
if field.starts_with(token) {
|
||||
return Some([1, 0, 0, 0, field.len()]);
|
||||
}
|
||||
let contiguous = field
|
||||
.match_indices(token)
|
||||
.map(|(start, _)| {
|
||||
let boundary = field[..start]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_none_or(|ch| !ch.is_alphanumeric());
|
||||
[2, usize::from(!boundary), 0, start, field.len()]
|
||||
})
|
||||
.min();
|
||||
if contiguous.is_some() {
|
||||
return contiguous;
|
||||
}
|
||||
|
||||
// ponytail: greedy subsequences can miss tighter later alignments; use dynamic
|
||||
// programming if that ranking precision becomes necessary.
|
||||
let mut wanted = token.chars();
|
||||
let mut next = wanted.next()?;
|
||||
let mut start = 0;
|
||||
let mut matched = 0;
|
||||
let mut boundaries = 0;
|
||||
let mut previous_is_word = false;
|
||||
for (position, ch) in field.chars().enumerate() {
|
||||
if ch == next {
|
||||
if matched == 0 {
|
||||
start = position;
|
||||
}
|
||||
matched += 1;
|
||||
boundaries += usize::from(previous_is_word);
|
||||
if let Some(ch) = wanted.next() {
|
||||
next = ch;
|
||||
} else {
|
||||
return Some([
|
||||
3,
|
||||
boundaries,
|
||||
position - start + 1 - matched,
|
||||
start,
|
||||
field.len(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
previous_is_word = ch.is_alphanumeric();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn append_rows(
|
||||
rows: &mut Vec<AlignedRow>,
|
||||
before: usize,
|
||||
@@ -2151,6 +2473,73 @@ mod tests {
|
||||
assert_eq!(editor.diff().unwrap().focus, Focus::After);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revision_matches_rank_later_contiguous_boundaries_and_tight_subsequences() {
|
||||
assert_eq!(revision_match("r---f---s/topic/rfs", "rfs").unwrap()[0], 2);
|
||||
assert!(revision_match("topic/rfs", "rfs") < revision_match("topicxrfs", "rfs"));
|
||||
assert!(revision_match("r-f-s", "rfs") < revision_match("r---f---s", "rfs"));
|
||||
assert!(revision_match("über-parser", "üpr").is_some());
|
||||
assert!(revision_match("parser", "srp").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jujutsu_fuzzy_selectors_rank_labels_above_descriptions_in_both_stages() {
|
||||
for phase in [Phase::Before, Phase::After] {
|
||||
for (category_index, &category) in RepoKind::Jujutsu.categories().iter().enumerate() {
|
||||
let mut state = DiffState::new();
|
||||
state.apply_result(
|
||||
1,
|
||||
Ok(Outcome::Catalog(Catalog {
|
||||
kind: RepoKind::Jujutsu,
|
||||
entries: vec![
|
||||
revision(
|
||||
RevisionTarget::Working,
|
||||
"Working checkout",
|
||||
"",
|
||||
Category::Revisions,
|
||||
),
|
||||
revision(
|
||||
RevisionTarget::Commit("description-target".into()),
|
||||
"unrelated",
|
||||
"sm/fin-1205-pilot-assignment-schema",
|
||||
category,
|
||||
),
|
||||
revision(
|
||||
RevisionTarget::Commit("immutable-fuzzy-target".into()),
|
||||
"sm/fin-1205-pilot-assignment-schema",
|
||||
"",
|
||||
category,
|
||||
),
|
||||
revision(
|
||||
RevisionTarget::Commit("immutable-exact-target".into()),
|
||||
"f1205pas",
|
||||
"",
|
||||
category,
|
||||
),
|
||||
],
|
||||
notice: None,
|
||||
})),
|
||||
);
|
||||
state.phase = phase;
|
||||
state.category = category_index;
|
||||
state.query = "F1205PAS".into();
|
||||
state.refilter();
|
||||
assert_eq!(
|
||||
state
|
||||
.entries()
|
||||
.skip(1)
|
||||
.map(|entry| &entry.target)
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
&RevisionTarget::Commit("immutable-exact-target".into()),
|
||||
&RevisionTarget::Commit("immutable-fuzzy-target".into()),
|
||||
&RevisionTarget::Commit("description-target".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_filters_literal_unicode_tokens_and_pins_working_in_each_category() {
|
||||
let mut editor = editor();
|
||||
@@ -2263,9 +2652,19 @@ mod tests {
|
||||
assert_eq!(state.phase, Phase::After);
|
||||
assert_eq!(state.query, "origin");
|
||||
assert_eq!(state.category, 0);
|
||||
assert_eq!(state.selected, 1);
|
||||
assert_eq!(
|
||||
state.entries().nth(state.selected).unwrap().target,
|
||||
RevisionTarget::FetchRemote("origin".into())
|
||||
);
|
||||
assert_eq!(state.entries().len(), 3);
|
||||
editor.handle(Input::Down);
|
||||
let remote_position = state
|
||||
.entries()
|
||||
.position(|entry| matches!(&entry.target, RevisionTarget::Commit(id) if id == "remote-commit"))
|
||||
.unwrap();
|
||||
editor.handle(Input::Home);
|
||||
for _ in 0..remote_position {
|
||||
editor.handle(Input::Down);
|
||||
}
|
||||
editor.handle(Input::Enter);
|
||||
let (id, operation) = editor.take_diff_request().unwrap();
|
||||
assert!(matches!(operation, Operation::Compare { after, .. }
|
||||
@@ -2879,6 +3278,218 @@ mod tests {
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_refresh_preserves_noops_errors_and_view_state_but_invalidates_old_identity() {
|
||||
let mut state = live_state(vec!["old".into()], vec!["new".into()], false);
|
||||
state.loading = false;
|
||||
state.pending = None;
|
||||
state.request_kind = None;
|
||||
state.comparison_id = Some(7);
|
||||
state.word_wrap = true;
|
||||
state.view_mode = ViewMode::Unified;
|
||||
state.comparison_mode = ComparisonMode::Snapshots;
|
||||
state.split_axis = crate::panes::SplitAxis::Horizontal;
|
||||
state.files_visible = false;
|
||||
state.reviewed_files.insert(0);
|
||||
state.error = Some("previous notice".into());
|
||||
let files = state.files.as_mut().unwrap();
|
||||
files.set_view(FileView::Tree);
|
||||
files.start_search();
|
||||
files.query = "file".into();
|
||||
files.refilter();
|
||||
let original = format!("{state:?}");
|
||||
state.queue_working_refresh();
|
||||
assert!(matches!(
|
||||
state.take_request(),
|
||||
Some(Operation::RefreshWorking {
|
||||
comparison_id: 7,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(!state.apply_result(8, Ok(Outcome::WorkingRefresh(None))));
|
||||
assert_eq!(format!("{state:?}"), original);
|
||||
|
||||
state.queue_working_refresh();
|
||||
state.take_request();
|
||||
assert!(state.apply_result(9, Err("refresh failed".into())));
|
||||
assert_eq!(state.comparison_id, Some(7));
|
||||
assert_eq!(state.comparison.as_ref().unwrap().files[0].after, ["new"]);
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
assert_eq!(state.error.as_deref(), Some("refresh failed"));
|
||||
|
||||
state.queue_working_refresh();
|
||||
state.take_request();
|
||||
let mut replacement = live_state(vec!["old".into()], vec!["ahead".into()], false)
|
||||
.comparison
|
||||
.take()
|
||||
.unwrap();
|
||||
replacement.files.insert(0, comparison().files.remove(0));
|
||||
assert!(state.apply_result(10, Ok(Outcome::WorkingRefresh(Some(replacement)))));
|
||||
assert_eq!(state.comparison_id, Some(10));
|
||||
assert_eq!(state.file, 1);
|
||||
assert_eq!(state.focus, Focus::After);
|
||||
assert!(state.word_wrap && !state.files_visible);
|
||||
assert_eq!(state.split_axis, crate::panes::SplitAxis::Horizontal);
|
||||
assert_eq!(state.view_mode, ViewMode::Unified);
|
||||
assert_eq!(state.comparison_mode, ComparisonMode::Snapshots);
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
let files = state.files.as_ref().unwrap();
|
||||
assert_eq!(files.query, "file");
|
||||
assert_eq!(files.view, FileView::Tree);
|
||||
assert!(files.searching);
|
||||
assert_eq!(
|
||||
files.entries().nth(files.selected).unwrap().path,
|
||||
Path::new("file")
|
||||
);
|
||||
let refreshed = format!("{state:?}");
|
||||
assert!(!state.apply_result(7, Ok(Outcome::WorkingRefresh(None))));
|
||||
assert_eq!(format!("{state:?}"), refreshed);
|
||||
state.after.as_mut().unwrap().target = RevisionTarget::Commit("historical".into());
|
||||
state.queue_working_refresh();
|
||||
assert!(!state.request_queued());
|
||||
state.after.as_mut().unwrap().target = RevisionTarget::Working;
|
||||
state.load_file(0);
|
||||
let user_request = format!("{state:?}");
|
||||
state.queue_working_refresh();
|
||||
assert_eq!(format!("{state:?}"), user_request);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewed_files_use_highlighted_catalog_indices_not_sorted_or_filtered_rows() {
|
||||
let mut state = DiffState::new();
|
||||
let mut changes = comparison();
|
||||
changes.files[0].path = "nested/z.txt".into();
|
||||
changes.files[1].path = "nested/a.txt".into();
|
||||
state.request_kind = Some(RequestKind::Comparison);
|
||||
state.apply_result(1, Ok(Outcome::Comparison(changes)));
|
||||
for view in [FileView::List, FileView::Tree] {
|
||||
let files = state.files.as_mut().unwrap();
|
||||
files.set_view(view);
|
||||
files.clear_filter();
|
||||
files.start_search();
|
||||
for ch in "nested/a".chars() {
|
||||
files.handle_filter_input(&Input::Char(ch));
|
||||
}
|
||||
files.reveal_path(Path::new("nested/a.txt"));
|
||||
assert_eq!(state.file, 0);
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([1]));
|
||||
assert_eq!(state.toggle_reviewed(), Ok(false));
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
}
|
||||
let files = state.files.as_mut().unwrap();
|
||||
files.clear_filter();
|
||||
files.finish_search();
|
||||
let selected = files.entries().position(|entry| entry.is_dir).unwrap();
|
||||
files.selected = selected;
|
||||
assert_eq!(
|
||||
state.toggle_reviewed(),
|
||||
Err("Select a changed file, not a directory")
|
||||
);
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
let files = state.files.as_mut().unwrap();
|
||||
files.start_search();
|
||||
for ch in "missing".chars() {
|
||||
files.handle_filter_input(&Input::Char(ch));
|
||||
}
|
||||
assert_eq!(state.toggle_reviewed(), Err("No changed file is selected"));
|
||||
state.files = Some(FilePicker::from_paths(
|
||||
PathBuf::new(),
|
||||
[(0, PathBuf::from("ordinary.txt"))],
|
||||
));
|
||||
assert_eq!(state.toggle_reviewed(), Err("No changed file is selected"));
|
||||
state.focus = Focus::Before;
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
assert!(state.select_file_index(2));
|
||||
state.focus = Focus::Before;
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0, 2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewed_files_survive_lazy_details_and_views_but_reset_on_comparison() {
|
||||
let mut state = DiffState::new();
|
||||
assert_eq!(state.toggle_reviewed(), Err("No comparison is open"));
|
||||
let mut changes = comparison();
|
||||
changes.files[0].loaded = false;
|
||||
changes.files[0].before.clear();
|
||||
changes.files[0].after.clear();
|
||||
state.request_kind = Some(RequestKind::Comparison);
|
||||
state.apply_result(1, Ok(Outcome::Comparison(changes)));
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
let file = comparison().files.remove(0);
|
||||
state.apply_result(
|
||||
2,
|
||||
Ok(Outcome::File {
|
||||
comparison_id: 1,
|
||||
file_index: 0,
|
||||
file,
|
||||
}),
|
||||
);
|
||||
assert!(state.selected_file_loaded());
|
||||
state.set_view_mode(ViewMode::Unified);
|
||||
state.set_view_mode(ViewMode::Split);
|
||||
state.split_axis = crate::panes::SplitAxis::Horizontal;
|
||||
assert!(state.select_file_index(1));
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
state.before = Some(revision(
|
||||
RevisionTarget::Commit("base".into()),
|
||||
"base",
|
||||
"",
|
||||
Category::Commits,
|
||||
));
|
||||
state.after = Some(revision(
|
||||
RevisionTarget::Commit("head".into()),
|
||||
"head",
|
||||
"",
|
||||
Category::Commits,
|
||||
));
|
||||
state.toggle_comparison_mode().unwrap();
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
assert_eq!(state.toggle_reviewed(), Err("No comparison is open"));
|
||||
state.apply_result(3, Ok(Outcome::Comparison(comparison())));
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
state.request_kind = Some(RequestKind::Comparison);
|
||||
state.apply_result(4, Ok(Outcome::Comparison(comparison())));
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_review_marks_clear_only_for_changed_bytes_or_eof() {
|
||||
for (after, eof, lines) in [
|
||||
(vec!["changed".into()], false, vec!["changed".into()]),
|
||||
(
|
||||
vec!["changed".into()],
|
||||
true,
|
||||
vec!["changed".into(), String::new()],
|
||||
),
|
||||
(Vec::new(), false, vec![String::new()]),
|
||||
] {
|
||||
let mut state = live_state(vec!["base".into()], after, eof);
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
state.refresh_after(&lines, 1);
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
state.refresh_after(&lines, 2);
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
state.refresh_after(&["edited".into()], 3);
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
state.refresh_after(&["edited".into(), String::new()], 4);
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
state.refresh_after(&["edited".into()], 5);
|
||||
assert!(state.reviewed_files.is_empty());
|
||||
}
|
||||
let mut state = live_state(vec!["base".into()], vec!["changed".into()], false);
|
||||
state.comparison.as_mut().unwrap().files[0].loaded = false;
|
||||
assert_eq!(state.toggle_reviewed(), Ok(true));
|
||||
state.refresh_after(&["edited".into()], 1);
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unified_groups_changed_runs_and_roundtrips_side_specific_source_rows() {
|
||||
let mut state = live_state(
|
||||
@@ -3299,4 +3910,48 @@ mod tests {
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_action_keeps_filter_input_and_rejects_superseded_comparisons() {
|
||||
let mut editor = editor();
|
||||
open(&mut editor);
|
||||
editor
|
||||
.bind("normal", "m", Some("core.diff.mode.toggle".into()))
|
||||
.unwrap();
|
||||
text(&mut editor, "m");
|
||||
assert_eq!(editor.diff().unwrap().query, "m");
|
||||
assert_eq!(
|
||||
editor.diff().unwrap().comparison_mode,
|
||||
ComparisonMode::BranchChanges
|
||||
);
|
||||
editor.handle(Input::Ctrl('u'));
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Enter);
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Enter);
|
||||
let (old, _) = editor.take_diff_request().unwrap();
|
||||
editor.execute("diff.mode.toggle");
|
||||
assert!(!editor.apply_diff_result(old, Err("superseded".into())));
|
||||
let (current, operation) = editor.take_diff_request().unwrap();
|
||||
assert!(matches!(
|
||||
operation,
|
||||
Operation::Compare {
|
||||
mode: ComparisonMode::Snapshots,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(editor.apply_diff_result(current, Err("retryable".into())));
|
||||
assert_eq!(editor.diff().unwrap().phase, Phase::After);
|
||||
assert_eq!(editor.diff().unwrap().error.as_deref(), Some("retryable"));
|
||||
editor.execute("diff.mode.toggle");
|
||||
let (_, operation) = editor.take_diff_request().unwrap();
|
||||
assert!(matches!(
|
||||
operation,
|
||||
Operation::Compare {
|
||||
mode: ComparisonMode::BranchChanges,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(editor.diff().unwrap().error.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+1448
-76
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
mod battery;
|
||||
mod blame;
|
||||
pub mod chrome;
|
||||
pub mod clipboard;
|
||||
pub mod diff;
|
||||
|
||||
+6
-2
@@ -12,7 +12,7 @@ Usage: vamp [--config PATH | --no-config] [FILE]
|
||||
Default keys (configurable with config.bind):
|
||||
All modes: Ctrl+p command palette; type to filter, Ctrl+j/k or arrows select, Enter accept, Esc cancel
|
||||
Normal/Visual: Space is leader; Space Space opens palette; Esc cancels prefix
|
||||
Normal leader: Space w saves; Space b n/p/l/d next/previous/list/close buffer
|
||||
Normal leader: Space w saves; Space b n/p/l/d next/previous/pick/close buffer
|
||||
Files: Space o floating tree INSERT; Space e docked tree NORMAL (right by default)
|
||||
tree NORMAL: hjkl navigate; INSERT: type to filter, Ctrl+hjkl navigate; Enter toggles/opens
|
||||
i filters; Esc retains filter and returns NORMAL, then editor; Ctrl+u clears in either mode
|
||||
@@ -23,12 +23,14 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
|
||||
Normal/Visual: f<char>/F<char> seek next/previous on current line; 2fx seeks second x
|
||||
gf follows Markdown file links/reference links and heading fragments (no LSP)
|
||||
Normal/Visual: gt first line, gb last line, at first nonblank; Esc cancels g
|
||||
Normal/Visual: <line>g jumps to that one-based source line at first nonblank, clamped at EOF
|
||||
Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported)
|
||||
_ splits above/below, | left/right; Option/Alt+h/j/k/l focuses neighboring panes
|
||||
Ctrl+Option/Alt+h/l previous/next tab; :tab.new opens current buffer in another tab
|
||||
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
|
||||
u undo, U redo, : command, / search, n/N next/previous match
|
||||
dd deletes lines; cc changes lines and enters Insert; counts: 3dd, 2cc; Esc cancels prefix
|
||||
yy copies whole lines; dd deletes them; cc changes them and enters Insert; counts: 3yy, 2dd, 2cc
|
||||
Linewise p/P paste below/above the current row; characterwise p/P paste after/before the grapheme
|
||||
Normal/Visual: . repeats last native edit at current targets (one undo group)
|
||||
v characterwise select, V linewise select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below
|
||||
y/d/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it
|
||||
@@ -54,6 +56,7 @@ Save actions: core.save, core.force_save, core.save_and_quit, core.force_save_an
|
||||
Reload preserves/clamps views and clears stale undo/redo; snapshots never reload
|
||||
Dirty buffers keep edits; missing/unreadable/invalid changed files keep text and warn
|
||||
Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffer_delete [N], :buffer_force_delete [N]
|
||||
:buffers / Space b l searches IDs/paths; Enter switches, Esc/Ctrl+p cancels, Ctrl+u clears
|
||||
Aliases: bnext, bprev, bdelete, bdelete!; arguments and safety rules are unchanged
|
||||
:quit refuses any dirty buffer; both save-and-quit commands require all buffers clean
|
||||
Restart: :restart closes clean buffers; unsaved buffers remain open and block restart with a warning
|
||||
@@ -71,6 +74,7 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
|
||||
Normal [/] explicitly expands context; Insert brackets remain literal text
|
||||
Normal Space v cycles split/unified; Space s flips left/right vs above/below split
|
||||
Defaults: config.diff_view_mode = \"split\"; config.diff_split_orientation = \"vertical\"
|
||||
Working After follows descendant checkouts; config.diff_follow_working_copy = false disables it
|
||||
Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly
|
||||
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
||||
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
|
||||
|
||||
+221
-27
@@ -1,7 +1,7 @@
|
||||
//! Read-only active-repository discovery and isolated revision comparison.
|
||||
use crate::diff::{
|
||||
Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision, RevisionTarget,
|
||||
line_hunks,
|
||||
Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus, RepoKind, Revision,
|
||||
RevisionTarget, line_hunks,
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
@@ -307,6 +307,10 @@ struct PreparedComparison {
|
||||
left: Snapshot,
|
||||
right: Snapshot,
|
||||
files: Vec<(PathBuf, Option<PathBuf>, FileStatus)>,
|
||||
before: String,
|
||||
after: String,
|
||||
working_after: bool,
|
||||
working_anchor: Option<String>,
|
||||
}
|
||||
|
||||
pub struct Repository {
|
||||
@@ -664,6 +668,27 @@ impl Repository {
|
||||
checked(run(cmd, vec![], &self.cancelled)?)?;
|
||||
self.private = Some((root.clone(), root));
|
||||
}
|
||||
// Only the private store gains alternates; active refs and objects stay untouched.
|
||||
let objects = self.git(
|
||||
&self.store,
|
||||
&[
|
||||
"rev-parse",
|
||||
"--path-format=absolute",
|
||||
"--git-path",
|
||||
"objects",
|
||||
],
|
||||
)?;
|
||||
let mut alternate = vec![b'"'];
|
||||
for &byte in objects.strip_suffix(b"\n").unwrap_or(&objects) {
|
||||
if (b' '..=b'~').contains(&byte) && !matches!(byte, b'"' | b'\\') {
|
||||
alternate.push(byte);
|
||||
} else {
|
||||
write!(&mut alternate, "\\{byte:03o}").unwrap();
|
||||
}
|
||||
}
|
||||
alternate.extend_from_slice(b"\"\n");
|
||||
let store = &self.private.as_ref().unwrap().1;
|
||||
fs::write(store.join("objects/info/alternates"), alternate).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
fn load_remote(&mut self, name: &str, reference: Option<&str>) -> Result<(), String> {
|
||||
@@ -681,10 +706,7 @@ impl Repository {
|
||||
let (root, store) = self.private.as_ref().unwrap();
|
||||
if self.kind == RepoKind::Jujutsu {
|
||||
if !self.registered.contains(name) {
|
||||
self.jj(
|
||||
root,
|
||||
&["git", "remote", "add", "--fetch-tags=all", "--", name, &url],
|
||||
)?;
|
||||
self.jj(root, &["git", "remote", "add", "--", name, &url])?;
|
||||
self.registered.insert(name.into());
|
||||
}
|
||||
self.jj(
|
||||
@@ -1279,6 +1301,87 @@ impl Repository {
|
||||
self.commit_snapshot(&store, &id)
|
||||
}
|
||||
}
|
||||
fn merge_base(&mut self, before: &str, after: &str) -> Result<String, String> {
|
||||
const JJ_ROOT: &str = "0000000000000000000000000000000000000000";
|
||||
if self.kind == RepoKind::Jujutsu && (before == JJ_ROOT || after == JJ_ROOT) {
|
||||
return Ok(JJ_ROOT.into());
|
||||
}
|
||||
let store = self
|
||||
.private
|
||||
.as_ref()
|
||||
.map_or(&self.store, |(_, store)| store);
|
||||
let mut command = git_command(store);
|
||||
command.args(["merge-base", "--all", before, after]);
|
||||
let output = run(command, vec![], &self.cancelled)?;
|
||||
if output.code == Some(1) && output.stdout.is_empty() {
|
||||
// Every jj history has the synthetic root, absent from Git's graph.
|
||||
return if self.kind == RepoKind::Jujutsu {
|
||||
Ok(JJ_ROOT.into())
|
||||
} else {
|
||||
Err(
|
||||
"Branch changes: no merge base; use Snapshots to compare unrelated histories"
|
||||
.into(),
|
||||
)
|
||||
};
|
||||
}
|
||||
let bases = text(checked(output).map_err(|e| format!("Cannot resolve merge base: {e}"))?)?;
|
||||
let mut bases = bases.lines();
|
||||
let base = bases.next().ok_or("Branch changes: no merge base")?;
|
||||
if bases.next().is_some() {
|
||||
return Err(
|
||||
"Branch changes: multiple merge bases; select Snapshots or different endpoints"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(base.into())
|
||||
}
|
||||
fn working_anchor(&self) -> Result<Option<String>, String> {
|
||||
if self.kind == RepoKind::Jujutsu {
|
||||
return Ok(Some(
|
||||
text(self.jj(
|
||||
&self.root,
|
||||
&["log", "--no-graph", "-r", "@", "-T", "change_id"],
|
||||
)?)?
|
||||
.trim()
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let mut command = git_command(&self.store);
|
||||
command.args(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
|
||||
let output = run(command, vec![], &self.cancelled)?;
|
||||
if output.code == Some(1) {
|
||||
return Ok(None); // An unborn checkout has no commit anchor yet.
|
||||
}
|
||||
Ok(Some(text(checked(output)?)?.trim().into()))
|
||||
}
|
||||
fn working_advanced(&self, before: Option<&str>, after: Option<&str>) -> Result<bool, String> {
|
||||
if before == after || after.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(before) = before else {
|
||||
return Ok(true);
|
||||
};
|
||||
if self.kind == RepoKind::Jujutsu {
|
||||
let revset = format!(
|
||||
"ancestors(@) & change_id({})",
|
||||
serde_json::to_string(before).unwrap()
|
||||
);
|
||||
return Ok(!self
|
||||
.jj(
|
||||
&self.root,
|
||||
&["log", "--no-graph", "-r", &revset, "-T", "change_id"],
|
||||
)?
|
||||
.is_empty());
|
||||
}
|
||||
let mut command = git_command(&self.store);
|
||||
command.args(["merge-base", "--is-ancestor", before, after.unwrap()]);
|
||||
let output = run(command, vec![], &self.cancelled)?;
|
||||
if output.code == Some(1) {
|
||||
Ok(false)
|
||||
} else {
|
||||
checked(output).map(|_| true)
|
||||
}
|
||||
}
|
||||
/// Freeze comparison sources and return path/status metadata only.
|
||||
/// A new comparison replaces sources retained for the previous request ID.
|
||||
pub fn compare(
|
||||
@@ -1286,29 +1389,125 @@ impl Repository {
|
||||
comparison_id: u64,
|
||||
before: &Revision,
|
||||
after: &Revision,
|
||||
mode: ComparisonMode,
|
||||
buffers: &[(PathBuf, String)],
|
||||
) -> Result<Comparison, String> {
|
||||
self.comparison = None;
|
||||
let mut left = self.snapshot(&before.target)?;
|
||||
let working_after = after.target == RevisionTarget::Working;
|
||||
let mut right = if before.target == after.target && (!working_after || buffers.is_empty()) {
|
||||
self.check_cancelled()?;
|
||||
let resolved = if mode == ComparisonMode::BranchChanges
|
||||
&& before.target != RevisionTarget::Working
|
||||
&& after.target != RevisionTarget::Working
|
||||
{
|
||||
let (_, before_id) = self.resolve(&before.target)?;
|
||||
let (_, after_id) = self.resolve(&after.target)?;
|
||||
let base = self.merge_base(&before_id, &after_id)?;
|
||||
let label = format!(
|
||||
"merge base of {} and {} · {base}",
|
||||
before.label, after.label
|
||||
);
|
||||
Some((
|
||||
RevisionTarget::Commit(base),
|
||||
RevisionTarget::Commit(after_id),
|
||||
label,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (before_target, after_target, before_label) = resolved.as_ref().map_or(
|
||||
(&before.target, &after.target, before.label.as_str()),
|
||||
|(before, after, label)| (before, after, label.as_str()),
|
||||
);
|
||||
let working_after = *after_target == RevisionTarget::Working;
|
||||
let working_anchor = if working_after {
|
||||
self.working_anchor()?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let left = self.snapshot(before_target)?;
|
||||
let same = before_target == after_target && (!working_after || buffers.is_empty());
|
||||
let right = if same {
|
||||
BTreeMap::new()
|
||||
} else if working_after {
|
||||
self.working_snapshot(buffers)?
|
||||
} else {
|
||||
self.snapshot(&after.target)?
|
||||
self.snapshot(after_target)?
|
||||
};
|
||||
let same = before.target == after.target && (!working_after || buffers.is_empty());
|
||||
let mut prepared = PreparedComparison {
|
||||
id: comparison_id,
|
||||
left,
|
||||
right,
|
||||
files: vec![],
|
||||
before: before_label.into(),
|
||||
after: after.label.clone(),
|
||||
working_after,
|
||||
working_anchor,
|
||||
};
|
||||
let comparison = self.build_comparison(&mut prepared, same)?;
|
||||
if working_after && self.working_anchor()? != prepared.working_anchor {
|
||||
return Err("Working checkout moved during comparison".into());
|
||||
}
|
||||
self.comparison = Some(prepared);
|
||||
Ok(comparison)
|
||||
}
|
||||
|
||||
/// Advance only a selected Working After, keeping its original Before frozen.
|
||||
pub fn refresh_working_after(
|
||||
&mut self,
|
||||
comparison_id: u64,
|
||||
new_id: u64,
|
||||
buffers: &[(PathBuf, String)],
|
||||
) -> Result<Option<Comparison>, String> {
|
||||
let Some(prepared) = self
|
||||
.comparison
|
||||
.as_ref()
|
||||
.filter(|prepared| prepared.id == comparison_id && prepared.working_after)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let anchor = self.working_anchor()?;
|
||||
if !self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())? {
|
||||
return Ok(None);
|
||||
}
|
||||
let right = self.working_snapshot(buffers)?;
|
||||
let mut prepared = self.comparison.take().unwrap();
|
||||
let previous_right = std::mem::replace(&mut prepared.right, right);
|
||||
let previous_files = std::mem::take(&mut prepared.files);
|
||||
let result = self
|
||||
.build_comparison(&mut prepared, false)
|
||||
.and_then(|comparison| {
|
||||
self.check_cancelled()?;
|
||||
if self.working_anchor()? != anchor {
|
||||
return Err("Working checkout moved during comparison".into());
|
||||
}
|
||||
Ok(comparison)
|
||||
});
|
||||
if result.is_ok() {
|
||||
prepared.id = new_id;
|
||||
prepared.working_anchor = anchor;
|
||||
} else {
|
||||
prepared.right = previous_right;
|
||||
prepared.files = previous_files;
|
||||
}
|
||||
self.comparison = Some(prepared);
|
||||
result.map(Some)
|
||||
}
|
||||
|
||||
fn build_comparison(
|
||||
&self,
|
||||
prepared: &mut PreparedComparison,
|
||||
same: bool,
|
||||
) -> Result<Comparison, String> {
|
||||
let left = &mut prepared.left;
|
||||
let right = &mut prepared.right;
|
||||
// Frozen checkout/conflict bytes have no object identity. Compare them in a
|
||||
// single batch, while immutable tree/tree comparisons need only object IDs.
|
||||
let frozen = |file: &FileData| {
|
||||
file.object.is_none() && matches!(file.mode.as_str(), "100644" | "100755" | "120000")
|
||||
};
|
||||
if !same && (left.values().any(frozen) || right.values().any(frozen)) {
|
||||
self.materialize(&mut left)?;
|
||||
self.materialize(&mut right)?;
|
||||
self.materialize(left)?;
|
||||
self.materialize(right)?;
|
||||
}
|
||||
let right_view = if same { &left } else { &right };
|
||||
let right_view = if same { &*left } else { &*right };
|
||||
let mut files = vec![];
|
||||
let paths: BTreeSet<_> = left.keys().chain(right_view.keys()).collect();
|
||||
for path in paths {
|
||||
@@ -1366,7 +1565,7 @@ impl Repository {
|
||||
.enumerate()
|
||||
.filter_map(|(i, f)| (!removed.contains(&i)).then_some(f))
|
||||
.collect();
|
||||
let editable_paths = if working_after {
|
||||
let editable_paths = if prepared.working_after {
|
||||
files
|
||||
.iter()
|
||||
.filter(|file| {
|
||||
@@ -1379,18 +1578,13 @@ impl Repository {
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
};
|
||||
self.comparison = Some(PreparedComparison {
|
||||
id: comparison_id,
|
||||
left,
|
||||
right,
|
||||
files: files
|
||||
.iter()
|
||||
.map(|file| (file.path.clone(), file.old_path.clone(), file.status))
|
||||
.collect(),
|
||||
});
|
||||
prepared.files = files
|
||||
.iter()
|
||||
.map(|file| (file.path.clone(), file.old_path.clone(), file.status))
|
||||
.collect();
|
||||
Ok(Comparison {
|
||||
before: before.label.clone(),
|
||||
after: after.label.clone(),
|
||||
before: prepared.before.clone(),
|
||||
after: prepared.after.clone(),
|
||||
files,
|
||||
root: self.root.clone(),
|
||||
editable_paths,
|
||||
|
||||
+101
-9
@@ -38,6 +38,7 @@ pub struct Config {
|
||||
pub file_picker_view: String,
|
||||
pub file_explorer_view: String,
|
||||
pub clipboard_mode: String,
|
||||
pub git_blame: bool,
|
||||
pub top_bar: ChromeLine,
|
||||
pub bottom_bar: ChromeLine,
|
||||
pub popup: PopupConfig,
|
||||
@@ -46,6 +47,7 @@ pub struct Config {
|
||||
pub diff_expand_lines: usize,
|
||||
pub diff_view_mode: crate::diff::ViewMode,
|
||||
pub diff_split_orientation: crate::panes::SplitAxis,
|
||||
pub diff_follow_working_copy: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -80,6 +82,7 @@ impl Default for Config {
|
||||
file_picker_view: "tree".into(),
|
||||
file_explorer_view: "tree".into(),
|
||||
clipboard_mode: "system".into(),
|
||||
git_blame: true,
|
||||
top_bar: ChromeLine::top(),
|
||||
bottom_bar: ChromeLine::bottom(),
|
||||
popup: PopupConfig::default(),
|
||||
@@ -88,6 +91,7 @@ impl Default for Config {
|
||||
diff_expand_lines: 15,
|
||||
diff_view_mode: crate::diff::ViewMode::Split,
|
||||
diff_split_orientation: crate::panes::SplitAxis::Vertical,
|
||||
diff_follow_working_copy: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,6 +113,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
file_picker_view,
|
||||
file_explorer_view,
|
||||
clipboard_mode,
|
||||
git_blame,
|
||||
top_bar,
|
||||
bottom_bar,
|
||||
popup,
|
||||
@@ -117,6 +122,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
diff_expand_lines,
|
||||
diff_view_mode,
|
||||
diff_split_orientation,
|
||||
diff_follow_working_copy,
|
||||
..
|
||||
} = *response;
|
||||
let mut config = Config::default();
|
||||
@@ -139,6 +145,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
config.file_picker_view = file_picker_view;
|
||||
config.file_explorer_view = file_explorer_view;
|
||||
config.clipboard_mode = clipboard_mode;
|
||||
config.git_blame = git_blame;
|
||||
config.top_bar = top_bar;
|
||||
config.bottom_bar = bottom_bar;
|
||||
config.popup = popup;
|
||||
@@ -147,6 +154,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
config.diff_expand_lines = diff_expand_lines;
|
||||
config.diff_view_mode = diff_view_mode;
|
||||
config.diff_split_orientation = diff_split_orientation;
|
||||
config.diff_follow_working_copy = diff_follow_working_copy;
|
||||
Ok(config)
|
||||
}
|
||||
Response::Error {
|
||||
@@ -453,6 +461,7 @@ struct ConfigFrame {
|
||||
file_picker_view: String,
|
||||
file_explorer_view: String,
|
||||
clipboard_mode: String,
|
||||
git_blame: bool,
|
||||
#[serde(deserialize_with = "crate::chrome::top")]
|
||||
top_bar: ChromeLine,
|
||||
#[serde(deserialize_with = "crate::chrome::bottom")]
|
||||
@@ -466,6 +475,7 @@ struct ConfigFrame {
|
||||
diff_expand_lines: usize,
|
||||
diff_view_mode: crate::diff::ViewMode,
|
||||
diff_split_orientation: crate::panes::SplitAxis,
|
||||
diff_follow_working_copy: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -854,6 +864,8 @@ fn read_frame(reader: &mut impl BufRead) -> Result<Vec<u8>, String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{editor::Input, session::EditorSession};
|
||||
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
@@ -980,7 +992,6 @@ end
|
||||
config.bind "E", config.core.edit
|
||||
config.bind "B", config.core.buffer
|
||||
config.bind "P", config.core.plugins
|
||||
config.bind "L", config.core.buffers
|
||||
config.bind "N", config.core.buffer_next
|
||||
config.bind "V", config.core.buffer_previous
|
||||
config.bind "D", config.core.buffer_delete
|
||||
@@ -1057,9 +1068,6 @@ end"#,
|
||||
assert_eq!(session.editor().lines(), &["base"]);
|
||||
session.handle(Input::Char('P'));
|
||||
assert!(session.editor().message().contains("text"));
|
||||
session.handle(Input::Char('L'));
|
||||
assert!(session.editor().message().contains("buffer.txt"));
|
||||
assert!(session.editor().message().contains("second.txt"));
|
||||
session.handle(Input::Char('N'));
|
||||
assert_eq!(session.editor().buffer_id(), 2);
|
||||
session.handle(Input::Char('V'));
|
||||
@@ -1204,14 +1212,90 @@ end
|
||||
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
||||
}
|
||||
for frame in [
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical"}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical"}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical"}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
||||
] {
|
||||
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_blame_boolean_config_typed_toggle_palette_and_wire_validation() {
|
||||
let fixture = Fixture::new();
|
||||
let config = fixture.file("config.rb", "Vamp.configure { |_config| }");
|
||||
assert!(load_config(&config).unwrap().git_blame);
|
||||
let config = fixture.file("config.rb", r#"
|
||||
Vamp.configure do |config|
|
||||
config.git_blame = false
|
||||
config.bind "G", config.core.git_blame.toggle
|
||||
raise "mutable blame action" unless config.core.git_blame.frozen? && config.core.git_blame.toggle.frozen?
|
||||
end
|
||||
"#);
|
||||
let mut session = EditorSession::open(None, vec![], Some(&config)).unwrap();
|
||||
assert!(!session.editor().git_blame());
|
||||
session.handle(Input::Char('G'));
|
||||
assert!(session.editor().git_blame());
|
||||
session.execute("git_blame.toggle extra");
|
||||
assert!(session.editor().git_blame());
|
||||
session.execute("git_blame.toggle");
|
||||
assert!(!session.editor().git_blame());
|
||||
session.handle(Input::Ctrl('p'));
|
||||
for ch in "git blame".chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
assert_eq!(session.editor().palette().unwrap().entries.len(), 1);
|
||||
session.handle(Input::Enter);
|
||||
assert!(session.editor().git_blame());
|
||||
for value in ["nil", "0", r#""false""#, "[]", "{}"] {
|
||||
let config = fixture.file(
|
||||
"config.rb",
|
||||
&format!("Vamp.configure {{ |config| config.git_blame = {value} }}"),
|
||||
);
|
||||
assert!(
|
||||
load_config(&config)
|
||||
.unwrap_err()
|
||||
.contains("git_blame must be true or false")
|
||||
);
|
||||
}
|
||||
let mut frame = serde_json::json!({
|
||||
"type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [],
|
||||
"leader": "space", "file_explorer_side": "right", "file_picker_view": "tree",
|
||||
"file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true,
|
||||
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
|
||||
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
|
||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||
"diff_follow_working_copy": true
|
||||
});
|
||||
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
|
||||
for value in [
|
||||
serde_json::Value::Null,
|
||||
serde_json::json!(0),
|
||||
serde_json::json!("false"),
|
||||
] {
|
||||
frame["git_blame"] = value;
|
||||
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
|
||||
}
|
||||
frame.as_object_mut().unwrap().remove("git_blame");
|
||||
assert!(serde_json::from_value::<Response>(frame).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_diff_tracking_rejects_non_boolean_configuration() {
|
||||
let fixture = Fixture::new();
|
||||
for value in ["nil", "0", r#""false""#, "[]", "{}"] {
|
||||
let path = fixture.file(
|
||||
"config.rb",
|
||||
&format!("Vamp.configure {{ |config| config.diff_follow_working_copy = {value} }}"),
|
||||
);
|
||||
assert!(
|
||||
load_config(&path)
|
||||
.unwrap_err()
|
||||
.contains("diff_follow_working_copy must be true or false")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chrome_configuration_normalizes_ruby_values_and_retains_unspecified_defaults() {
|
||||
let fixture = Fixture::new();
|
||||
@@ -1363,6 +1447,11 @@ end
|
||||
("bar.top", r#"{mode: {leading_edge: "\n"}}"#, "single-line"),
|
||||
("bar.top", "{language: {padding: 65}}", "between 0 and 64"),
|
||||
("bar.bottom", "{tabs: {padding: '1'}}", "must be an Integer"),
|
||||
("bar.top", "{lsp: {margin: 65}}", "between 0 and 64"),
|
||||
("bar.bottom", "{lsp: {margin: -1}}", "between 0 and 64"),
|
||||
("bar.top", "{lsp: {margin: '1'}}", "must be an Integer"),
|
||||
("bar.bottom", "{lsp: {margin: 1.5}}", "must be an Integer"),
|
||||
("bar.top", "{lsp: {margin: nil}}", "must be an Integer"),
|
||||
("bar.top", "{mode: {title: nil}}", "must be a String"),
|
||||
(
|
||||
"bar.bottom",
|
||||
@@ -1425,7 +1514,8 @@ end
|
||||
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
||||
"popup": {"command": {}, "search": {}},
|
||||
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7,
|
||||
"diff_view_mode": "split", "diff_split_orientation": "vertical"
|
||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||
"diff_follow_working_copy": true
|
||||
});
|
||||
for setting in ["diff_context_lines", "diff_expand_lines"] {
|
||||
for value in [
|
||||
@@ -1837,10 +1927,12 @@ end"#,
|
||||
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
||||
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||||
"git_blame": true,
|
||||
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
||||
"popup": {"command": {}, "search": {}},
|
||||
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
|
||||
"diff_view_mode": "split", "diff_split_orientation": "vertical"
|
||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||
"diff_follow_working_copy": true
|
||||
});
|
||||
let Response::Config(config) = serde_json::from_value::<Response>(frame.clone()).unwrap()
|
||||
else {
|
||||
|
||||
+23
-3
@@ -35,9 +35,9 @@ module Vamp
|
||||
chrome_name!(name)
|
||||
style = chrome_hash!(value).to_h do |option, item|
|
||||
case option
|
||||
when "padding"
|
||||
when "padding", "margin", "leading_margin", "trailing_margin"
|
||||
unless item.is_a?(Integer) && item.between?(0, 64)
|
||||
raise ArgumentError, "padding must be an Integer between 0 and 64"
|
||||
raise ArgumentError, "#{option} must be an Integer between 0 and 64"
|
||||
end
|
||||
when "title", "leading_edge", "trailing_edge", "first_leading_edge"
|
||||
item = string!(item, option)
|
||||
@@ -136,6 +136,7 @@ module Vamp
|
||||
@file_picker_view = "tree"
|
||||
@file_explorer_view = "tree"
|
||||
@clipboard_mode = "system"
|
||||
@git_blame = true
|
||||
@bar = BarConfig.new
|
||||
@popup = PopupConfig.new
|
||||
@labels = {}
|
||||
@@ -143,6 +144,7 @@ module Vamp
|
||||
@diff_expand_lines = 15
|
||||
@diff_view_mode = "split"
|
||||
@diff_split_orientation = "vertical"
|
||||
@diff_follow_working_copy = true
|
||||
end
|
||||
|
||||
# @param path [String] relative to the configuration file
|
||||
@@ -187,6 +189,14 @@ module Vamp
|
||||
@clipboard_mode = nonempty_string!(mode, "clipboard mode")
|
||||
end
|
||||
|
||||
# @param enabled [Boolean]
|
||||
# @return [Boolean]
|
||||
def git_blame=(enabled)
|
||||
raise TypeError, "git_blame must be true or false" unless [true, false].include?(enabled)
|
||||
|
||||
@git_blame = enabled
|
||||
end
|
||||
|
||||
# @param values [Hash{String, Symbol => String}]
|
||||
# @return [Hash]
|
||||
def labels=(values)
|
||||
@@ -225,6 +235,14 @@ module Vamp
|
||||
@diff_split_orientation = orientation
|
||||
end
|
||||
|
||||
# @param enabled [Boolean]
|
||||
# @return [Boolean]
|
||||
def diff_follow_working_copy=(enabled)
|
||||
raise TypeError, "diff_follow_working_copy must be true or false" unless [true, false].include?(enabled)
|
||||
|
||||
@diff_follow_working_copy = enabled
|
||||
end
|
||||
|
||||
# @return [Module<Vamp::Core>] frozen namespace with catalog-generated Action methods
|
||||
def core
|
||||
Core
|
||||
@@ -256,10 +274,12 @@ module Vamp
|
||||
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings,
|
||||
"leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view,
|
||||
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
|
||||
"git_blame" => @git_blame,
|
||||
"top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels,
|
||||
"popup" => { "command" => @popup.command, "search" => @popup.search },
|
||||
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
|
||||
"diff_view_mode" => @diff_view_mode, "diff_split_orientation" => @diff_split_orientation }
|
||||
"diff_view_mode" => @diff_view_mode, "diff_split_orientation" => @diff_split_orientation,
|
||||
"diff_follow_working_copy" => @diff_follow_working_copy }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
+373
-25
@@ -12,8 +12,9 @@ use std::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
blame::BlameWorker,
|
||||
diff::{Focus, Operation as DiffOperation, Outcome as DiffOutcome},
|
||||
editor::{Cursor, Editor, Effect, Input, Invocation, Mode},
|
||||
editor::{BlameSource, Cursor, Editor, Effect, Input, Invocation, Mode},
|
||||
lsp::Lsp,
|
||||
plugin::{
|
||||
DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginEvent, PluginUpdate,
|
||||
@@ -78,10 +79,17 @@ impl DiffWorker {
|
||||
DiffOperation::Compare {
|
||||
before,
|
||||
after,
|
||||
mode,
|
||||
buffers,
|
||||
} => repository
|
||||
.compare(id, &before, &after, &buffers)
|
||||
.compare(id, &before, &after, mode, &buffers)
|
||||
.map(DiffOutcome::Comparison),
|
||||
DiffOperation::RefreshWorking {
|
||||
comparison_id,
|
||||
buffers,
|
||||
} => repository
|
||||
.refresh_working_after(comparison_id, id, &buffers)
|
||||
.map(DiffOutcome::WorkingRefresh),
|
||||
DiffOperation::LoadFile {
|
||||
comparison_id,
|
||||
file_index,
|
||||
@@ -172,9 +180,15 @@ pub struct EditorSession {
|
||||
pending_completion: bool,
|
||||
hover_owner: Option<String>,
|
||||
diff_workers: BTreeMap<usize, DiffWorker>,
|
||||
blame_worker: Option<BlameWorker>,
|
||||
blame_source: Option<BlameSource>,
|
||||
blame_lines: Option<Vec<Option<Arc<crate::blame::LineBlame>>>>,
|
||||
blame_request_id: u64,
|
||||
battery: Option<crate::battery::Battery>,
|
||||
battery_enabled: bool,
|
||||
last_file_refresh: Instant,
|
||||
last_diff_refresh: Instant,
|
||||
pub(crate) diff_follow_working_copy: bool,
|
||||
}
|
||||
|
||||
impl EditorSession {
|
||||
@@ -224,6 +238,7 @@ impl EditorSession {
|
||||
config.clipboard_mode
|
||||
));
|
||||
}
|
||||
editor.set_git_blame(config.git_blame);
|
||||
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
|
||||
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
|
||||
if let Err(error) =
|
||||
@@ -273,9 +288,15 @@ impl EditorSession {
|
||||
pending_completion: false,
|
||||
hover_owner: None,
|
||||
diff_workers: BTreeMap::new(),
|
||||
blame_worker: None,
|
||||
blame_source: None,
|
||||
blame_lines: None,
|
||||
blame_request_id: 0,
|
||||
battery: None,
|
||||
battery_enabled: false,
|
||||
last_file_refresh: Instant::now(),
|
||||
last_diff_refresh: Instant::now(),
|
||||
diff_follow_working_copy: config.diff_follow_working_copy,
|
||||
};
|
||||
session.synchronize_documents();
|
||||
session.poll_battery();
|
||||
@@ -341,6 +362,7 @@ impl EditorSession {
|
||||
return false;
|
||||
};
|
||||
if !diff.loading
|
||||
&& !diff.refresh_pending()
|
||||
&& let Some(id) = worker.pending.take()
|
||||
{
|
||||
worker.cancelled_request.store(id, Ordering::Release);
|
||||
@@ -348,29 +370,28 @@ impl EditorSession {
|
||||
}
|
||||
true
|
||||
});
|
||||
let Some((id, operation)) = self.editor.take_diff_request() else {
|
||||
return;
|
||||
};
|
||||
let tab = self.editor.tab_id();
|
||||
if let std::collections::btree_map::Entry::Vacant(entry) = self.diff_workers.entry(tab) {
|
||||
match DiffWorker::start(self.editor.diff_root()) {
|
||||
Ok(worker) => {
|
||||
entry.insert(worker);
|
||||
}
|
||||
Err(error) => {
|
||||
self.editor.apply_diff_result(id, Err(error));
|
||||
return;
|
||||
while let Some((tab, id, operation)) = self.editor.take_next_diff_request() {
|
||||
if let std::collections::btree_map::Entry::Vacant(entry) = self.diff_workers.entry(tab)
|
||||
{
|
||||
match DiffWorker::start(self.editor.diff_root(tab)) {
|
||||
Ok(worker) => {
|
||||
entry.insert(worker);
|
||||
}
|
||||
Err(error) => {
|
||||
self.editor.apply_diff_result(id, Err(error));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(error) = self
|
||||
.diff_workers
|
||||
.get_mut(&tab)
|
||||
.expect("diff worker started")
|
||||
.request(id, operation)
|
||||
{
|
||||
self.editor.apply_diff_result(id, Err(error));
|
||||
self.diff_workers.remove(&tab);
|
||||
if let Err(error) = self
|
||||
.diff_workers
|
||||
.get_mut(&tab)
|
||||
.expect("diff worker started")
|
||||
.request(id, operation)
|
||||
{
|
||||
self.editor.apply_diff_result(id, Err(error));
|
||||
self.diff_workers.remove(&tab);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,6 +532,7 @@ impl EditorSession {
|
||||
}
|
||||
|
||||
fn synchronize_documents(&mut self) -> bool {
|
||||
let blame_changed = self.poll_blame();
|
||||
// Leaving a request's source invalidates it permanently, even if history returns there.
|
||||
if let Some(requests) = self.requests.get_mut("core")
|
||||
&& requests.latest.is_some_and(|(_, source)| {
|
||||
@@ -535,7 +557,7 @@ impl EditorSession {
|
||||
}
|
||||
if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) {
|
||||
self.documents.clear();
|
||||
return self.refresh_lsp_names();
|
||||
return self.refresh_lsp_names() | blame_changed;
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
if !self.editor.should_quit() {
|
||||
@@ -611,7 +633,7 @@ impl EditorSession {
|
||||
});
|
||||
}
|
||||
let mut failures = Vec::new();
|
||||
let mut changed = false;
|
||||
let mut changed = blame_changed;
|
||||
for event in &events {
|
||||
if let Err(error) = self.lsp.notify_document(event) {
|
||||
self.editor.set_message(format!("LSP: {error}"));
|
||||
@@ -1012,6 +1034,79 @@ impl EditorSession {
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn poll_blame(&mut self) -> bool {
|
||||
let mut changed = false;
|
||||
if !self
|
||||
.blame_source
|
||||
.as_ref()
|
||||
.is_some_and(|source| self.editor.blame_file_source_is_current(source))
|
||||
{
|
||||
self.blame_source = self.editor.blame_source();
|
||||
self.blame_request_id = self.blame_request_id.wrapping_add(1);
|
||||
self.blame_lines = None;
|
||||
changed |= self.editor.clear_line_blame();
|
||||
if let Some(source) = &self.blame_source {
|
||||
if self.blame_worker.is_none() {
|
||||
self.blame_worker = BlameWorker::start().ok();
|
||||
}
|
||||
if let Some(worker) = &mut self.blame_worker
|
||||
&& worker
|
||||
.request(
|
||||
self.blame_request_id,
|
||||
source.path.to_path_buf(),
|
||||
self.editor.lines().join("\n"),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
self.blame_worker = None;
|
||||
}
|
||||
} else {
|
||||
self.blame_worker = None;
|
||||
}
|
||||
}
|
||||
while let Some((id, result)) = self.blame_worker.as_mut().and_then(BlameWorker::poll) {
|
||||
changed |= self.apply_blame_result(id, result);
|
||||
}
|
||||
changed | self.refresh_line_blame()
|
||||
}
|
||||
|
||||
fn apply_blame_result(
|
||||
&mut self,
|
||||
id: u64,
|
||||
result: Result<Option<Vec<Option<Arc<crate::blame::LineBlame>>>>, String>,
|
||||
) -> bool {
|
||||
if id != self.blame_request_id
|
||||
|| !self
|
||||
.blame_source
|
||||
.as_ref()
|
||||
.is_some_and(|source| self.editor.blame_file_source_is_current(source))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.blame_lines = result.ok().flatten();
|
||||
self.refresh_line_blame()
|
||||
}
|
||||
|
||||
fn refresh_line_blame(&mut self) -> bool {
|
||||
if let Some(source) = &self.blame_source
|
||||
&& self.editor.blame_file_source_is_current(source)
|
||||
&& let Some(blame) = self
|
||||
.blame_lines
|
||||
.as_ref()
|
||||
.and_then(|lines| lines.get(self.editor.cursor().row))
|
||||
.and_then(Option::as_ref)
|
||||
{
|
||||
self.editor.set_cached_line_blame(source, blame.clone())
|
||||
} else {
|
||||
self.editor.clear_line_blame()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_blame(&mut self) -> bool {
|
||||
self.blame_source = None;
|
||||
self.poll_blame()
|
||||
}
|
||||
|
||||
fn poll_battery(&mut self) -> bool {
|
||||
let enabled = self.editor.label("battery").is_empty()
|
||||
&& [
|
||||
@@ -1056,6 +1151,16 @@ impl EditorSession {
|
||||
self.editor.refresh_files()
|
||||
}
|
||||
|
||||
fn refresh_working_diffs_at(&mut self, now: Instant) {
|
||||
if !self.diff_follow_working_copy
|
||||
|| now.saturating_duration_since(self.last_diff_refresh) < Duration::from_secs(1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.last_diff_refresh = now;
|
||||
self.editor.queue_working_refreshes();
|
||||
}
|
||||
|
||||
/// Drains ready plugin work without waiting for command or server responses.
|
||||
pub fn poll(&mut self) -> bool {
|
||||
self.poll_at(Instant::now())
|
||||
@@ -1065,6 +1170,7 @@ impl EditorSession {
|
||||
let mut changed = self.refresh_files_at(now);
|
||||
changed |= self.synchronize_documents();
|
||||
changed |= self.poll_battery();
|
||||
self.refresh_working_diffs_at(now);
|
||||
changed |= self.poll_diff();
|
||||
for update in self.lsp.poll() {
|
||||
match self.apply_update("core", update) {
|
||||
@@ -1127,6 +1233,248 @@ mod tests {
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn working_diff_polling_uses_injected_interval_and_never_queues_historical_or_disabled_views() {
|
||||
use crate::diff::{Catalog, Category, Comparison, RepoKind, Revision, RevisionTarget};
|
||||
let fixture = Fixture::new();
|
||||
fixture.file("buffer.txt", "base");
|
||||
for working in [true, false] {
|
||||
let mut session = fixture.open("");
|
||||
session.editor.execute("diff");
|
||||
let (id, _) = session.editor.take_diff_request().unwrap();
|
||||
session.editor.apply_diff_result(
|
||||
id,
|
||||
Ok(DiffOutcome::Catalog(Catalog {
|
||||
kind: RepoKind::Git,
|
||||
notice: None,
|
||||
entries: [
|
||||
RevisionTarget::Working,
|
||||
RevisionTarget::Commit("base".into()),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|target| Revision {
|
||||
target,
|
||||
label: "revision".into(),
|
||||
description: String::new(),
|
||||
category: Category::Commits,
|
||||
})
|
||||
.collect(),
|
||||
})),
|
||||
);
|
||||
session.editor.handle(Input::Down);
|
||||
session.editor.handle(Input::Enter);
|
||||
if !working {
|
||||
session.editor.handle(Input::Down);
|
||||
}
|
||||
session.editor.handle(Input::Enter);
|
||||
let (id, _) = session.editor.take_diff_request().unwrap();
|
||||
session.editor.apply_diff_result(
|
||||
id,
|
||||
Ok(DiffOutcome::Comparison(Comparison {
|
||||
root: fixture.0.clone(),
|
||||
before: "base".into(),
|
||||
after: "after".into(),
|
||||
files: Vec::new(),
|
||||
editable_paths: Default::default(),
|
||||
})),
|
||||
);
|
||||
let now = session.last_diff_refresh;
|
||||
session.diff_follow_working_copy = false;
|
||||
session.refresh_working_diffs_at(now + Duration::from_secs(1));
|
||||
assert!(session.editor.take_next_diff_request().is_none());
|
||||
session.diff_follow_working_copy = true;
|
||||
session.refresh_working_diffs_at(now + Duration::from_millis(999));
|
||||
assert!(session.editor.take_next_diff_request().is_none());
|
||||
session.refresh_working_diffs_at(now + Duration::from_secs(1));
|
||||
let request = session.editor.take_next_diff_request();
|
||||
assert_eq!(request.is_some(), working);
|
||||
if let Some((_, refresh, operation)) = request {
|
||||
assert!(
|
||||
matches!(operation, DiffOperation::RefreshWorking { comparison_id, .. }
|
||||
if comparison_id == id)
|
||||
);
|
||||
session.refresh_working_diffs_at(now + Duration::from_secs(2));
|
||||
assert!(session.editor.take_next_diff_request().is_none());
|
||||
assert!(
|
||||
!session
|
||||
.editor
|
||||
.apply_diff_result(refresh, Ok(DiffOutcome::WorkingRefresh(None)))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blame_sources_are_deduplicated_and_stale_results_cannot_reappear() {
|
||||
let fixture = Fixture::new();
|
||||
let path = fixture.file("source.txt", "first\nsecond\nthird");
|
||||
let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap();
|
||||
assert!(session.editor.git_blame());
|
||||
let first = session.blame_request_id;
|
||||
let original = Arc::new(crate::blame::LineBlame {
|
||||
commit: "0123456789".into(),
|
||||
author: "Author".into(),
|
||||
summary: "Original".into(),
|
||||
uncommitted: false,
|
||||
});
|
||||
let second = Arc::new(crate::blame::LineBlame {
|
||||
commit: "0123456789".into(),
|
||||
author: "Author".into(),
|
||||
summary: "Second".into(),
|
||||
uncommitted: false,
|
||||
});
|
||||
let blame = || {
|
||||
Ok(Some(vec![
|
||||
Some(original.clone()),
|
||||
Some(second.clone()),
|
||||
Some(second.clone()),
|
||||
]))
|
||||
};
|
||||
session.handle(Input::Down);
|
||||
assert_eq!(session.blame_request_id, first);
|
||||
session.blame_worker = None;
|
||||
assert!(session.apply_blame_result(first, blame()));
|
||||
assert_eq!(
|
||||
session.editor.line_blame(),
|
||||
Some("Author · Second (01234567)")
|
||||
);
|
||||
let label = session.editor.line_blame().unwrap().as_ptr();
|
||||
session.handle(Input::Down);
|
||||
assert_eq!(session.editor.line_blame().unwrap().as_ptr(), label);
|
||||
assert!(!session.poll_blame());
|
||||
session.handle(Input::Up);
|
||||
session.handle(Input::Up);
|
||||
assert_eq!(
|
||||
session.editor.line_blame(),
|
||||
Some("Author · Original (01234567)")
|
||||
);
|
||||
assert_eq!(session.blame_request_id, first);
|
||||
session.execute("tab.new");
|
||||
assert_eq!(session.blame_request_id, first);
|
||||
assert_eq!(
|
||||
session.editor.line_blame(),
|
||||
Some("Author · Original (01234567)")
|
||||
);
|
||||
let other = fixture.file("other.txt", "other");
|
||||
session.execute(&format!("edit {}", other.display()));
|
||||
assert!(session.editor.line_blame().is_none());
|
||||
assert!(!session.apply_blame_result(first, blame()));
|
||||
session.execute("buffer 1");
|
||||
assert!(!session.apply_blame_result(first, blame()));
|
||||
let source = session.editor.blame_source().unwrap();
|
||||
let before_edit = session.blame_request_id;
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::Char('x'));
|
||||
assert_ne!(session.blame_request_id, before_edit);
|
||||
assert!(!session.apply_blame_result(before_edit, blame()));
|
||||
assert!(!session.editor.set_line_blame(source, original.clone()));
|
||||
assert!(session.editor.line_blame().is_none());
|
||||
session.handle(Input::Escape);
|
||||
session.execute("git_blame.toggle");
|
||||
assert!(!session.editor.git_blame());
|
||||
assert!(session.blame_worker.is_none());
|
||||
assert!(session.blame_source.is_none());
|
||||
assert!(session.editor.line_blame().is_none());
|
||||
assert!(!session.apply_blame_result(session.blame_request_id, blame()));
|
||||
session.poll_blame();
|
||||
assert!(session.blame_worker.is_none());
|
||||
let config = fixture.file(
|
||||
"config.rb",
|
||||
"Vamp.configure { |config| config.git_blame = false }",
|
||||
);
|
||||
let mut disabled = EditorSession::open(Some(path), vec![], Some(&config)).unwrap();
|
||||
assert!(!disabled.editor.git_blame());
|
||||
assert!(disabled.blame_worker.is_none());
|
||||
disabled.poll_blame();
|
||||
assert!(disabled.blame_worker.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blame_navigation_uses_one_real_file_result_and_refreshes_its_baseline() {
|
||||
let fixture = Fixture::new();
|
||||
let path = fixture.file("source.txt", "first\nsecond\n");
|
||||
let git = |args: &[&str]| {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&fixture.0)
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_AUTHOR_NAME", "Author")
|
||||
.env("GIT_AUTHOR_EMAIL", "author@example.com")
|
||||
.env("GIT_COMMITTER_NAME", "Author")
|
||||
.env("GIT_COMMITTER_EMAIL", "author@example.com")
|
||||
.env("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z")
|
||||
.env("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
};
|
||||
git(&["init", "-q"]);
|
||||
git(&["add", "source.txt"]);
|
||||
git(&["commit", "-qm", "Original"]);
|
||||
fixture.file("source.txt", "first\nchanged\n");
|
||||
git(&["add", "source.txt"]);
|
||||
git(&["commit", "-qm", "Second"]);
|
||||
let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap();
|
||||
let ready = |session: &mut EditorSession| {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while session.blame_lines.is_none() {
|
||||
assert!(Instant::now() < deadline, "file attribution did not finish");
|
||||
session.poll();
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
};
|
||||
let first = session.blame_request_id;
|
||||
session.handle(Input::Down);
|
||||
assert_eq!(session.blame_request_id, first);
|
||||
ready(&mut session);
|
||||
assert!(session.editor.line_blame().unwrap().contains("Second"));
|
||||
session.handle(Input::Up);
|
||||
assert!(session.editor.line_blame().unwrap().contains("Original"));
|
||||
session.handle(Input::Down);
|
||||
assert!(session.editor.line_blame().unwrap().contains("Second"));
|
||||
session.handle(Input::Down);
|
||||
assert!(session.editor.line_blame().is_none());
|
||||
assert_eq!(session.blame_request_id, first);
|
||||
session.handle(Input::Up);
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::Char('x'));
|
||||
assert_ne!(session.blame_request_id, first);
|
||||
ready(&mut session);
|
||||
assert_eq!(session.editor.line_blame(), Some("Uncommitted"));
|
||||
session.handle(Input::Escape);
|
||||
let edited = session.blame_request_id;
|
||||
session.execute("save");
|
||||
assert_ne!(session.blame_request_id, edited);
|
||||
ready(&mut session);
|
||||
git(&["add", "source.txt"]);
|
||||
git(&["commit", "-qm", "Saved"]);
|
||||
let saved = session.blame_request_id;
|
||||
session.refresh_blame();
|
||||
assert_ne!(session.blame_request_id, saved);
|
||||
ready(&mut session);
|
||||
assert!(session.editor.line_blame().unwrap().contains("Saved"));
|
||||
fixture.file("source.txt", "first\nxchanged\n");
|
||||
let refreshed = session.blame_request_id;
|
||||
assert!(!session.editor.refresh_files());
|
||||
session.synchronize_documents();
|
||||
assert_ne!(session.blame_request_id, refreshed);
|
||||
ready(&mut session);
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::Char('x'));
|
||||
ready(&mut session);
|
||||
assert!(session.editor.dirty());
|
||||
fs::remove_file(path).unwrap();
|
||||
assert!(session.editor.refresh_files());
|
||||
session.synchronize_documents();
|
||||
assert!(session.blame_source.is_none());
|
||||
assert!(session.editor.line_blame().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_navigation_resolves_utf16_and_validates_full_ranges_atomically() {
|
||||
use crate::plugin::Utf16Range;
|
||||
|
||||
+916
-48
File diff suppressed because it is too large
Load Diff
+126
-1
@@ -7,7 +7,11 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use vamp::{diff::Phase, editor::Input, session::EditorSession};
|
||||
use vamp::{
|
||||
diff::{ComparisonMode, Phase},
|
||||
editor::Input,
|
||||
session::EditorSession,
|
||||
};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
@@ -111,6 +115,10 @@ fn typed_diff_edits_unsaved_working_after_and_preserves_undo_on_explicit_close()
|
||||
session.handle(Input::Enter);
|
||||
assert_eq!(session.editor().diff().unwrap().phase, Phase::After);
|
||||
session.handle(Input::Enter);
|
||||
assert_eq!(
|
||||
session.editor().diff().unwrap().comparison_mode,
|
||||
ComparisonMode::Snapshots
|
||||
);
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
@@ -485,3 +493,120 @@ fn readonly_jump_history_restores_the_comparison_file_and_source_cursor() {
|
||||
fs::read_to_string(root.join("first.rs")).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_changes_default_and_mode_switches_compare_the_selected_divergent_tips() {
|
||||
let fixture = Fixture::new();
|
||||
let root = fixture.0.join("repo");
|
||||
fixture.git(&["init", "-q"]);
|
||||
fs::write(root.join("common"), "common\n").unwrap();
|
||||
fixture.git(&["add", "."]);
|
||||
fixture.git(&["commit", "-qm", "common"]);
|
||||
let base = fixture.git(&["rev-parse", "HEAD"]);
|
||||
fs::write(root.join("base-only"), "base branch\n").unwrap();
|
||||
fixture.git(&["add", "."]);
|
||||
fixture.git(&["commit", "-qm", "base only"]);
|
||||
let before = fixture.git(&["rev-parse", "HEAD"]);
|
||||
fixture.git(&["checkout", "-qb", "feature", &base]);
|
||||
fs::write(root.join("feature-only"), "feature branch\n").unwrap();
|
||||
fixture.git(&["add", "."]);
|
||||
fixture.git(&["commit", "-qm", "feature only"]);
|
||||
let after = fixture.git(&["rev-parse", "HEAD"]);
|
||||
let config = fixture.0.join("config.rb");
|
||||
fs::write(&config, "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.bind 'ctrl+b', config.core.diff.mode.toggle\nend\n").unwrap();
|
||||
let mut session =
|
||||
EditorSession::open(Some(root.join("common")), vec![], Some(&config)).unwrap();
|
||||
session.execute("diff");
|
||||
wait_until(&mut session, |session| {
|
||||
!session.editor().diff().unwrap().loading
|
||||
});
|
||||
assert_eq!(
|
||||
session.editor().diff().unwrap().comparison_mode,
|
||||
ComparisonMode::BranchChanges
|
||||
);
|
||||
session.handle(Input::Ctrl('b'));
|
||||
assert_eq!(
|
||||
session.editor().diff().unwrap().comparison_mode,
|
||||
ComparisonMode::Snapshots
|
||||
);
|
||||
session.handle(Input::Ctrl('p'));
|
||||
type_query(&mut session, "diff.mode.toggle");
|
||||
session.handle(Input::Enter);
|
||||
assert_eq!(
|
||||
session.editor().diff().unwrap().comparison_mode,
|
||||
ComparisonMode::BranchChanges
|
||||
);
|
||||
for id in [&before, &after] {
|
||||
type_query(&mut session, id);
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
}
|
||||
wait_until(&mut session, |session| {
|
||||
session.editor().diff().unwrap().selected_file_loaded()
|
||||
});
|
||||
let diff = session.editor().diff().unwrap();
|
||||
let comparison = diff.comparison.as_ref().unwrap();
|
||||
assert_eq!(comparison.files.len(), 1);
|
||||
assert_eq!(comparison.files[0].path, Path::new("feature-only"));
|
||||
assert_eq!(comparison.files[0].after, ["feature branch"]);
|
||||
assert_eq!(
|
||||
comparison.before,
|
||||
format!("merge base of {before} and {after} · {base}")
|
||||
);
|
||||
assert_eq!(diff.before.as_ref().unwrap().label, before);
|
||||
|
||||
session.execute("diff.mode.toggle");
|
||||
wait_until(&mut session, |session| {
|
||||
session.editor().diff().unwrap().selected_file_loaded()
|
||||
});
|
||||
let comparison = session
|
||||
.editor()
|
||||
.diff()
|
||||
.unwrap()
|
||||
.comparison
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(comparison.before, before);
|
||||
assert_eq!(comparison.after, after);
|
||||
assert_eq!(
|
||||
comparison
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| file.path.as_path())
|
||||
.collect::<Vec<_>>(),
|
||||
[Path::new("base-only"), Path::new("feature-only")]
|
||||
);
|
||||
|
||||
// Superseding an in-flight recomparison must not restore the old mode's result.
|
||||
session.execute("diff.mode.toggle");
|
||||
session.execute("diff.mode.toggle");
|
||||
session.execute("diff.mode.toggle");
|
||||
wait_until(&mut session, |session| {
|
||||
session.editor().diff().unwrap().selected_file_loaded()
|
||||
});
|
||||
let diff = session.editor().diff().unwrap();
|
||||
assert_eq!(diff.comparison_mode, ComparisonMode::BranchChanges);
|
||||
assert_eq!(diff.comparison.as_ref().unwrap().files.len(), 1);
|
||||
assert_eq!(diff.before.as_ref().unwrap().label, before);
|
||||
assert_eq!(diff.after.as_ref().unwrap().label, after);
|
||||
session.execute("diff.close");
|
||||
assert_eq!(session.editor().path(), Some(root.join("common").as_path()));
|
||||
|
||||
session.execute("diff");
|
||||
wait_until(&mut session, |session| {
|
||||
!session.editor().diff().unwrap().loading
|
||||
});
|
||||
session.handle(Input::Enter);
|
||||
let diff = session.editor().diff().unwrap();
|
||||
assert_eq!(diff.phase, Phase::After);
|
||||
assert_eq!(diff.comparison_mode, ComparisonMode::Snapshots);
|
||||
session.execute("diff.mode.toggle");
|
||||
assert_eq!(
|
||||
session.editor().diff().unwrap().comparison_mode,
|
||||
ComparisonMode::Snapshots
|
||||
);
|
||||
assert_eq!(
|
||||
session.editor().message(),
|
||||
"Working checkout requires Snapshots"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use vamp::{
|
||||
diff::{Category, Phase, RevisionTarget},
|
||||
editor::Input,
|
||||
session::EditorSession,
|
||||
};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Fixture {
|
||||
root: PathBuf,
|
||||
target: String,
|
||||
decoy: String,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"vamp-diff-search-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
fs::create_dir_all(root.join("repo")).unwrap();
|
||||
let mut fixture = Self {
|
||||
root,
|
||||
target: String::new(),
|
||||
decoy: String::new(),
|
||||
};
|
||||
fixture.git(&["init", "-q", "--initial-branch=fixture-main"]);
|
||||
fixture.commit("foundation", "base");
|
||||
fixture.target = fixture.commit("Über parser target", "target");
|
||||
fixture.decoy = fixture.commit(
|
||||
&format!("release archaeology mentioning {}", fixture.target),
|
||||
"decoy",
|
||||
);
|
||||
for name in [
|
||||
"a-r_e_l_e_a_s_e",
|
||||
"release-candidate",
|
||||
"zrelease",
|
||||
"tie-a",
|
||||
"tie-b",
|
||||
] {
|
||||
fixture.git(&["branch", name, &fixture.decoy]);
|
||||
fixture.git(&["tag", name, &fixture.decoy]);
|
||||
}
|
||||
for name in ["release", "feature/parser-rewrite", "feature/Über-parser"] {
|
||||
fixture.git(&["branch", name, &fixture.target]);
|
||||
fixture.git(&["tag", name, &fixture.target]);
|
||||
}
|
||||
fixture
|
||||
}
|
||||
|
||||
fn git(&self, args: &[&str]) -> String {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(self.root.join("repo"))
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_AUTHOR_NAME", "Diff Search Fixture")
|
||||
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
|
||||
.env("GIT_COMMITTER_NAME", "Diff Search Fixture")
|
||||
.env("GIT_COMMITTER_EMAIL", "fixture@example.invalid")
|
||||
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
|
||||
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git {args:?}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().to_owned()
|
||||
}
|
||||
|
||||
fn commit(&self, message: &str, content: &str) -> String {
|
||||
fs::write(self.root.join("repo/source.txt"), content).unwrap();
|
||||
self.git(&["add", "source.txt"]);
|
||||
self.git(&["commit", "-qm", message]);
|
||||
self.git(&["rev-parse", "HEAD"])
|
||||
}
|
||||
|
||||
fn session(&self) -> EditorSession {
|
||||
let config = self.root.join("config.rb");
|
||||
fs::write(
|
||||
&config,
|
||||
"Vamp.configure do |config|\n config.clipboard_mode = 'separate'\nend\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut session = EditorSession::open(
|
||||
Some(self.root.join("repo/source.txt")),
|
||||
vec![],
|
||||
Some(&config),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(session.editor().startup_diagnostic().is_none());
|
||||
session.execute(":diff");
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
|
||||
});
|
||||
session
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !ready(session) {
|
||||
session.poll();
|
||||
assert!(Instant::now() < deadline, "{:?}", session.editor().diff());
|
||||
if let Some(diff) = session.editor().diff() {
|
||||
assert!(diff.error.is_none(), "{:?}", diff.error);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
fn query(session: &mut EditorSession, text: &str) {
|
||||
session.handle(Input::Ctrl('u'));
|
||||
for ch in text.chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
}
|
||||
|
||||
fn category(session: &mut EditorSession, category: Category) {
|
||||
query(session, "");
|
||||
for _ in 0..3 {
|
||||
let diff = session.editor().diff().unwrap();
|
||||
if diff.catalog.as_ref().unwrap().kind.categories()[diff.category] == category {
|
||||
return;
|
||||
}
|
||||
session.handle(Input::Char('\t'));
|
||||
}
|
||||
panic!("category unavailable: {category:?}");
|
||||
}
|
||||
|
||||
fn labels(session: &EditorSession) -> Vec<String> {
|
||||
let diff = session.editor().diff().unwrap();
|
||||
assert_eq!(
|
||||
diff.entries().next().unwrap().target,
|
||||
RevisionTarget::Working
|
||||
);
|
||||
diff.entries()
|
||||
.skip(1)
|
||||
.map(|entry| entry.label.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_and_tag_fuzzy_ranking_and_empty_order_match_in_both_selectors() {
|
||||
let fixture = Fixture::new();
|
||||
let mut session = fixture.session();
|
||||
for phase in [Phase::Before, Phase::After] {
|
||||
assert_eq!(session.editor().diff().unwrap().phase, phase);
|
||||
for kind in [Category::Branches, Category::Tags] {
|
||||
category(&mut session, kind);
|
||||
let catalogue_order = labels(&session);
|
||||
query(&mut session, " ");
|
||||
assert_eq!(labels(&session), catalogue_order);
|
||||
|
||||
query(&mut session, "release");
|
||||
assert_eq!(
|
||||
labels(&session),
|
||||
[
|
||||
"release",
|
||||
"release-candidate",
|
||||
"zrelease",
|
||||
"a-r_e_l_e_a_s_e"
|
||||
]
|
||||
);
|
||||
query(&mut session, "ftprw");
|
||||
assert_eq!(labels(&session), ["feature/parser-rewrite"]);
|
||||
query(&mut session, "tie");
|
||||
let ties: Vec<_> = catalogue_order
|
||||
.iter()
|
||||
.filter(|label| label.starts_with("tie-"))
|
||||
.cloned()
|
||||
.collect();
|
||||
assert_eq!(&labels(&session)[..ties.len()], ties);
|
||||
query(&mut session, "no-such-reference-anywhere");
|
||||
assert!(labels(&session).is_empty());
|
||||
assert_eq!(session.editor().diff().unwrap().selected, 0);
|
||||
}
|
||||
if phase == Phase::Before {
|
||||
// Even an unmatched query leaves Working selectable and pinned first.
|
||||
session.handle(Input::Enter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_tokens_and_commit_ids_search_fields_without_cross_field_subsequences() {
|
||||
let fixture = Fixture::new();
|
||||
let mut session = fixture.session();
|
||||
for phase in [Phase::Before, Phase::After] {
|
||||
assert_eq!(session.editor().diff().unwrap().phase, phase);
|
||||
category(&mut session, Category::Branches);
|
||||
for text in [
|
||||
format!("{} ÜBPR", &fixture.target[..10]),
|
||||
format!("übpr {}", &fixture.target[..10]),
|
||||
] {
|
||||
query(&mut session, &text);
|
||||
assert_eq!(labels(&session), ["feature/Über-parser"]);
|
||||
}
|
||||
|
||||
category(&mut session, Category::Commits);
|
||||
for text in [&fixture.target[..], &fixture.target[..10]] {
|
||||
query(&mut session, text);
|
||||
assert_eq!(
|
||||
labels(&session),
|
||||
[fixture.target.clone(), fixture.decoy.clone()]
|
||||
);
|
||||
}
|
||||
query(&mut session, "ÜB PRSR");
|
||||
assert_eq!(labels(&session), [fixture.target.clone()]);
|
||||
// The ID and description can satisfy separate tokens, but not one token
|
||||
// that would require a subsequence crossing between unrelated fields.
|
||||
query(&mut session, &format!("{} übpr", &fixture.target[..10]));
|
||||
assert_eq!(labels(&session), [fixture.target.clone()]);
|
||||
query(&mut session, &format!("{}über", fixture.target));
|
||||
assert!(labels(&session).is_empty());
|
||||
if phase == Phase::Before {
|
||||
session.handle(Input::Enter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_ranked_branch_uses_its_actual_commit_in_before_and_after() {
|
||||
let fixture = Fixture::new();
|
||||
for ranked_phase in [Phase::Before, Phase::After] {
|
||||
let mut session = fixture.session();
|
||||
if ranked_phase == Phase::After {
|
||||
session.handle(Input::Enter);
|
||||
}
|
||||
category(&mut session, Category::Branches);
|
||||
query(&mut session, "release");
|
||||
assert_eq!(labels(&session)[0], "release");
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
if ranked_phase == Phase::Before {
|
||||
// Working selects snapshot mode rather than merge-base mode.
|
||||
session.handle(Input::Enter);
|
||||
}
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
|
||||
});
|
||||
let diff = session.editor().diff().unwrap();
|
||||
let ranked = if ranked_phase == Phase::Before {
|
||||
diff.before.as_ref().unwrap()
|
||||
} else {
|
||||
diff.after.as_ref().unwrap()
|
||||
};
|
||||
assert_eq!(ranked.label, "release");
|
||||
assert_eq!(
|
||||
ranked.target,
|
||||
RevisionTarget::Commit(fixture.target.clone())
|
||||
);
|
||||
let comparison = diff.comparison.as_ref().unwrap();
|
||||
assert_eq!(comparison.files.len(), 1);
|
||||
assert_eq!(comparison.files[0].path, Path::new("source.txt"));
|
||||
let (before, after) = if ranked_phase == Phase::Before {
|
||||
(["target"], ["decoy"])
|
||||
} else {
|
||||
(["decoy"], ["target"])
|
||||
};
|
||||
assert_eq!(comparison.files[0].before, before);
|
||||
assert_eq!(comparison.files[0].after, after);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use vamp::editor::{Cursor, Editor, Effect, Input, Mode};
|
||||
fn editor() -> Editor {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor
|
||||
.apply(vec![Effect::Replace(
|
||||
"first\n e\u{301}second\n\tthird".into(),
|
||||
)])
|
||||
.unwrap();
|
||||
editor
|
||||
}
|
||||
fn keys(editor: &mut Editor, text: &str) {
|
||||
for key in text.chars() {
|
||||
editor.handle(Input::Char(key));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn counted_g_is_one_based_clamps_and_leaves_bare_prefixes_intact() {
|
||||
let mut editor = editor();
|
||||
let revision = editor.text_revision();
|
||||
keys(&mut editor, "2g");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 });
|
||||
assert_eq!(editor.count(), None);
|
||||
keys(&mut editor, "999999999999999999999999999999999g");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 1 });
|
||||
keys(&mut editor, "1g");
|
||||
assert_eq!(editor.cursor().row, 0);
|
||||
keys(&mut editor, "gb");
|
||||
assert_eq!(editor.cursor().row, 2);
|
||||
keys(&mut editor, "gt");
|
||||
assert_eq!(editor.cursor().row, 0);
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
}
|
||||
#[test]
|
||||
fn counted_g_extends_visual_selection_and_respects_g_overrides() {
|
||||
let mut editor = editor();
|
||||
keys(&mut editor, "v3g");
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 2, column: 1 });
|
||||
assert_eq!(editor.selections()[0].start, Cursor { row: 0, column: 0 });
|
||||
editor.handle(Input::Escape);
|
||||
keys(&mut editor, "gt");
|
||||
editor.bind("normal", "g", None).unwrap();
|
||||
keys(&mut editor, "2g");
|
||||
assert_eq!(editor.cursor().row, 0);
|
||||
editor
|
||||
.bind("normal", "g", Some("core.down".into()))
|
||||
.unwrap();
|
||||
keys(&mut editor, "2g");
|
||||
assert_eq!(editor.cursor().row, 2);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use vamp::editor::{Editor, Effect, Input};
|
||||
#[test]
|
||||
fn yy_copies_current_whole_line_for_paste_below_it() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor.set_clipboard_mode("separate").unwrap();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("first\nsecond\n".into())])
|
||||
.unwrap();
|
||||
for key in "llyy".chars() {
|
||||
editor.handle(Input::Char(key));
|
||||
}
|
||||
assert_eq!(editor.cursor().column, 2);
|
||||
assert_eq!(editor.context().text, "first\nsecond\n");
|
||||
editor.handle(Input::Char('p'));
|
||||
assert_eq!(editor.context().text, "first\nfirst\nsecond\n");
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use vamp::editor::{Cursor, Editor, Input, Mode};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"vamp-normal-editing-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
|
||||
fn editor(&self, text: &str) -> Editor {
|
||||
let path = self.0.join("buffer.txt");
|
||||
fs::write(&path, text).unwrap();
|
||||
let mut editor = Editor::open(Some(path), vec!["text".into()]).unwrap();
|
||||
editor.set_clipboard_mode("separate").unwrap();
|
||||
editor
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn keys(editor: &mut Editor, text: &str) {
|
||||
for ch in text.chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_and_delete_line_suffix_preserve_newlines_and_group_insert_undo() {
|
||||
for operation in ["C", "D", "9C", "9D"] {
|
||||
let fixture = Fixture::new();
|
||||
let source = "ae\u{301}👩🚀tail\nnext\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "2l");
|
||||
let caret = editor.cursor();
|
||||
keys(&mut editor, operation);
|
||||
assert_eq!(editor.context().text, "ae\u{301}\nnext\n", "{operation}");
|
||||
if operation.ends_with('C') {
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
keys(&mut editor, "replacement");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, "ae\u{301}replacement\nnext\n");
|
||||
} else {
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
}
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suffix_commands_at_last_grapheme_and_empty_lines_never_remove_newlines() {
|
||||
for operation in ["C", "D"] {
|
||||
for source in ["a👩🚀\nnext\n", "a👩🚀\nnext"] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "$");
|
||||
keys(&mut editor, operation);
|
||||
assert_eq!(editor.context().text, source.replacen("a👩🚀", "a", 1));
|
||||
if editor.mode() == Mode::Insert {
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
}
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("\nnext\n");
|
||||
keys(&mut editor, operation);
|
||||
assert_eq!(editor.context().text, "\nnext\n");
|
||||
assert_eq!(
|
||||
editor.mode(),
|
||||
if operation == "C" {
|
||||
Mode::Insert
|
||||
} else {
|
||||
Mode::Normal
|
||||
}
|
||||
);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yank_line_suffix_is_characterwise_and_does_not_create_an_undo_entry() {
|
||||
for operation in ["Y", "8Y"] {
|
||||
let fixture = Fixture::new();
|
||||
let source = "a👩🚀e\u{301}tail\nnext\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "l");
|
||||
let caret = editor.cursor();
|
||||
keys(&mut editor, operation);
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert!(!editor.dirty());
|
||||
keys(&mut editor, "j0P");
|
||||
assert_eq!(
|
||||
editor.context().text,
|
||||
"a👩🚀e\u{301}tail\n👩🚀e\u{301}tailnext\n"
|
||||
);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yank_and_cancelled_replace_preserve_the_previous_repeatable_edit() {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("abc\nxyz\n");
|
||||
keys(&mut editor, "xY3r");
|
||||
editor.handle(Input::Escape);
|
||||
keys(&mut editor, "j0.");
|
||||
assert_eq!(editor.context().text, "bc\nyz\n");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, "bc\nxyz\n");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, "abc\nxyz\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_consumes_a_literal_character_and_replaces_whole_counted_graphemes() {
|
||||
for (command, expected) in [
|
||||
("rx", "ax👩🚀z\nnext\n"),
|
||||
("r2", "a2👩🚀z\nnext\n"),
|
||||
("r界", "a界👩🚀z\nnext\n"),
|
||||
("r\t", "a\t👩🚀z\nnext\n"),
|
||||
("2rQ", "aQQz\nnext\n"),
|
||||
("9rQ", "aQQQ\nnext\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let source = "ae\u{301}👩🚀z\nnext\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "l");
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.context().text, expected, "{command:?}");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 });
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("\nnext\n");
|
||||
keys(&mut editor, "rQ");
|
||||
assert_eq!(editor.context().text, "\nnext\n");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn substitute_deletes_counted_whole_graphemes_and_groups_replacement_with_undo() {
|
||||
for (command, after_delete, expected) in [
|
||||
("s", "a👩🚀z\nnext\n", "aQ👩🚀z\nnext\n"),
|
||||
("2s", "az\nnext\n", "aQz\nnext\n"),
|
||||
("9s", "a\nnext\n", "aQ\nnext\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let source = "ae\u{301}👩🚀z\nnext\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "l");
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.context().text, after_delete, "{command}");
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
keys(&mut editor, "Q");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, expected);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 });
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_delete_counts_before_and_after_operator_multiply_in_both_directions() {
|
||||
let source = "one two three four five six seven eight nine\n";
|
||||
for (position, command, expected) in [
|
||||
("", "dw", "two three four five six seven eight nine\n"),
|
||||
("", "d2w", "three four five six seven eight nine\n"),
|
||||
("", "2dw", "three four five six seven eight nine\n"),
|
||||
("", "2d3w", "seven eight nine\n"),
|
||||
("2w", "db", "one three four five six seven eight nine\n"),
|
||||
("4w", "d2b", "one two five six seven eight nine\n"),
|
||||
("4w", "2db", "one two five six seven eight nine\n"),
|
||||
("6w", "2d3b", "seven eight nine\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, position);
|
||||
let caret = editor.cursor();
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.context().text, expected, "{position} {command}");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_delete_uses_unicode_word_motion_boundaries_across_punctuation_and_rows() {
|
||||
for (source, command, expected) in [
|
||||
("e\u{301}lan 世界 fin\n", "d2w", "fin\n"),
|
||||
("e\u{301}lan 世界 fin\n", "2wd2b", "fin\n"),
|
||||
("alpha... beta gamma\n", "d2w", "beta gamma\n"),
|
||||
("alpha... beta gamma\n", "2wd2b", "beta gamma\n"),
|
||||
("👩🚀!!! next\n", "dw", "next\n"),
|
||||
("one\ntwo three\n", "d2w", "three\n"),
|
||||
("one\ntwo three\n", "2wd2b", "three\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.context().text, expected, "{command} on {source:?}");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_delete_handles_eof_and_saturating_counts_without_splitting_graphemes() {
|
||||
let source = "e\u{301}lan 世界";
|
||||
for (command, expected) in [
|
||||
("d2w".to_owned(), ""),
|
||||
(format!("{}d2w", usize::MAX), ""),
|
||||
("$d3b".to_owned(), "界"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, &command);
|
||||
assert_eq!(editor.context().text, expected, "{command}");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_operator_and_replace_cancellation_clear_counts_without_editing() {
|
||||
for prefix in ["3d", "d2", "2d3", "3c", "c2", "3r"] {
|
||||
let fixture = Fixture::new();
|
||||
let source = "abcd\nnext\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, prefix);
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, source, "{prefix}");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 0 });
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert!(!editor.dirty());
|
||||
keys(&mut editor, "x");
|
||||
assert_eq!(editor.context().text, "bcd\nnext\n", "{prefix}");
|
||||
}
|
||||
for input in [
|
||||
Input::Enter,
|
||||
Input::Char('\n'),
|
||||
Input::Paste("ignored".into()),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("abcd\nnext\n");
|
||||
keys(&mut editor, "3r");
|
||||
editor.handle(input);
|
||||
keys(&mut editor, "lx");
|
||||
assert_eq!(editor.context().text, "acd\nnext\n");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_edit_recipes_repeat_against_the_current_text_and_undo_as_single_changes() {
|
||||
for (source, first, after_first, position, expected) in [
|
||||
(
|
||||
"prefix one\nlongprefix two\n",
|
||||
"7lCnew",
|
||||
"prefix new\nlongprefix two\n",
|
||||
"j011l",
|
||||
"prefix new\nlongprefix new\n",
|
||||
),
|
||||
(
|
||||
"prefix one\nlongprefix two\n",
|
||||
"7lD",
|
||||
"prefix \nlongprefix two\n",
|
||||
"j011l",
|
||||
"prefix \nlongprefix \n",
|
||||
),
|
||||
(
|
||||
"e\u{301}abc\n👩🚀xyz\n",
|
||||
"rQ",
|
||||
"Qabc\n👩🚀xyz\n",
|
||||
"j0",
|
||||
"Qabc\nQxyz\n",
|
||||
),
|
||||
(
|
||||
"e\u{301}abc\n👩🚀xyz\n",
|
||||
"sQ",
|
||||
"Qabc\n👩🚀xyz\n",
|
||||
"j0",
|
||||
"Qabc\nQxyz\n",
|
||||
),
|
||||
(
|
||||
"one two keep\nthree four stay\n",
|
||||
"d2w",
|
||||
"keep\nthree four stay\n",
|
||||
"j0",
|
||||
"keep\nstay\n",
|
||||
),
|
||||
(
|
||||
"one two keep\nthree four stay\n",
|
||||
"2wd2b",
|
||||
"keep\nthree four stay\n",
|
||||
"j02w",
|
||||
"keep\nstay\n",
|
||||
),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, first);
|
||||
if editor.mode() == Mode::Insert {
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
assert_eq!(editor.context().text, after_first, "{first}");
|
||||
keys(&mut editor, position);
|
||||
keys(&mut editor, ".");
|
||||
assert_eq!(editor.context().text, expected, "{first} then {position}.");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, after_first);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dd_and_cc_keep_linewise_behavior_with_counts_after_the_operator() {
|
||||
let source = "1\n2\n3\n4\n5\n6\n7\n8\n9\n";
|
||||
for (command, remaining) in [
|
||||
("dd", "2\n3\n4\n5\n6\n7\n8\n9\n"),
|
||||
("2dd", "3\n4\n5\n6\n7\n8\n9\n"),
|
||||
("d2d", "3\n4\n5\n6\n7\n8\n9\n"),
|
||||
("2d3d", "7\n8\n9\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.context().text, remaining, "{command}");
|
||||
keys(&mut editor, "P");
|
||||
assert_eq!(editor.context().text, source, "{command} linewise paste");
|
||||
}
|
||||
for (command, remaining) in [
|
||||
("cc", "2\n3\n4\n5\n6\n7\n8\n9\n"),
|
||||
("2cc", "3\n4\n5\n6\n7\n8\n9\n"),
|
||||
("c2c", "3\n4\n5\n6\n7\n8\n9\n"),
|
||||
("2c3c", "7\n8\n9\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
assert_eq!(editor.context().text, format!("\n{remaining}"), "{command}");
|
||||
keys(&mut editor, "Q");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, format!("Q\n{remaining}"));
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_multicarets_edit_whole_graphemes_and_yank_matching_suffix_fragments() {
|
||||
let source = "a👩🚀tail\nbe\u{301}rest\n";
|
||||
for (command, expected) in [
|
||||
("D", "a\nb\n"),
|
||||
("CQ", "aQ\nbQ\n"),
|
||||
("rQ", "aQtail\nbQrest\n"),
|
||||
("sQ", "aQtail\nbQrest\n"),
|
||||
("YP", "a👩🚀tail👩🚀tail\nbe\u{301}reste\u{301}rest\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "l");
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
let carets = editor.carets();
|
||||
assert_eq!(carets.len(), 2);
|
||||
keys(&mut editor, command);
|
||||
if editor.mode() == Mode::Insert {
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
assert_eq!(editor.context().text, expected, "{command}");
|
||||
assert_eq!(editor.selection_count(), 2);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
}
|
||||
for command in ["d2w", "2wd2b"] {
|
||||
let fixture = Fixture::new();
|
||||
let source = "one two keep\nthree four stay\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.context().text, "keep\nstay\n", "{command}");
|
||||
assert_eq!(editor.selection_count(), 2);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.selection_count(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remapped_core_edit_actions_keep_counts_pending_input_and_clipboard_semantics() {
|
||||
for (action, original_key, position, command, expected) in [
|
||||
("core.change_to_line_end", "C", "l", "qX", "aX\nnext\n"),
|
||||
("core.delete_to_line_end", "D", "l", "q", "a\nnext\n"),
|
||||
(
|
||||
"core.copy_to_line_end",
|
||||
"Y",
|
||||
"l",
|
||||
"qj0P",
|
||||
"abc def ghi\nbc def ghinext\n",
|
||||
),
|
||||
(
|
||||
"core.replace_character",
|
||||
"r",
|
||||
"l",
|
||||
"qZ",
|
||||
"aZc def ghi\nnext\n",
|
||||
),
|
||||
(
|
||||
"core.substitute_character",
|
||||
"s",
|
||||
"l",
|
||||
"qZ",
|
||||
"aZc def ghi\nnext\n",
|
||||
),
|
||||
("core.delete_word_forward", "d", "", "2q", "ghi\nnext\n"),
|
||||
("core.delete_word_backward", "d", "2w", "2q", "ghi\nnext\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("abc def ghi\nnext\n");
|
||||
editor.bind("normal", original_key, None).unwrap();
|
||||
editor.bind("normal", "q", Some(action.into())).unwrap();
|
||||
keys(&mut editor, position);
|
||||
keys(&mut editor, command);
|
||||
if editor.mode() == Mode::Insert {
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
assert_eq!(editor.context().text, expected, "{action}");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use vamp::editor::{Cursor, Editor, Input, Mode};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"vamp-paste-shapes-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
|
||||
fn editor(&self, text: &str) -> Editor {
|
||||
let path = self.0.join("buffer.txt");
|
||||
fs::write(&path, text).unwrap();
|
||||
let mut editor = Editor::open(Some(path), vec!["text".into()]).unwrap();
|
||||
editor.set_clipboard_mode("separate").unwrap();
|
||||
editor
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn keys(editor: &mut Editor, text: &str) {
|
||||
for ch in text.chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_line_yank_pastes_above_or_below_the_target_and_keeps_indentation() {
|
||||
let source = " \t界e\u{301}👩🚀\nother\nlast\n";
|
||||
for (paste, expected, row) in [
|
||||
('p', " \t界e\u{301}👩🚀\nother\n \t界e\u{301}👩🚀\nlast\n", 2),
|
||||
('P', " \t界e\u{301}👩🚀\n \t界e\u{301}👩🚀\nother\nlast\n", 1),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "3l");
|
||||
let yank_caret = editor.cursor();
|
||||
keys(&mut editor, "yy");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), yank_caret);
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert!(!editor.dirty());
|
||||
keys(&mut editor, "j2l");
|
||||
let paste_caret = editor.cursor();
|
||||
editor.handle(Input::Char(paste));
|
||||
assert_eq!(editor.context().text, expected, "{paste}");
|
||||
assert_eq!(editor.cursor(), Cursor { row, column: 2 });
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), paste_caret);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_line_paste_handles_unterminated_rows_empty_rows_and_the_final_empty_row() {
|
||||
for (source, position, paste, expected, row) in [
|
||||
("top\n last", "j2l", 'p', "top\n last\n last", 2),
|
||||
("top\n last", "j2l", 'P', "top\n last\n last", 1),
|
||||
("top\n last\n", "j2l", 'p', "top\n last\n last\n", 2),
|
||||
("top\n last\n", "j2l", 'P', "top\n last\n last\n", 1),
|
||||
("top\n\nend", "j", 'p', "top\n\n\nend", 2),
|
||||
("top\n\nend", "j", 'P', "top\n\n\nend", 1),
|
||||
("top\n", "j", 'p', "top\n\n", 2),
|
||||
("top\n", "j", 'P', "top\n\n", 1),
|
||||
("", "", 'p', "\n", 1),
|
||||
("", "", 'P', "\n", 0),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, position);
|
||||
let caret = editor.cursor();
|
||||
keys(&mut editor, "yy");
|
||||
editor.handle(Input::Char(paste));
|
||||
assert_eq!(
|
||||
editor.context().text,
|
||||
expected,
|
||||
"{source:?} {position}yy{paste}"
|
||||
);
|
||||
assert_eq!(
|
||||
editor.cursor(),
|
||||
Cursor {
|
||||
row,
|
||||
column: caret.column
|
||||
}
|
||||
);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counted_line_yanks_multiply_prefixes_and_clamp_at_the_last_row() {
|
||||
let source = "one\ntwo\nthree\nfour\nfive\nsix\nseven";
|
||||
for (command, expected) in [
|
||||
("2yy", "one\none\ntwo\ntwo\nthree\nfour\nfive\nsix\nseven"),
|
||||
("y2y", "one\none\ntwo\ntwo\nthree\nfour\nfive\nsix\nseven"),
|
||||
(
|
||||
"2y3y",
|
||||
"one\none\ntwo\nthree\nfour\nfive\nsix\ntwo\nthree\nfour\nfive\nsix\nseven",
|
||||
),
|
||||
(
|
||||
"9yy",
|
||||
"one\none\ntwo\nthree\nfour\nfive\nsix\nseven\ntwo\nthree\nfour\nfive\nsix\nseven",
|
||||
),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.context().text, source, "{command}");
|
||||
keys(&mut editor, "p");
|
||||
assert_eq!(editor.context().text, expected, "{command}p");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_character_yank_pastes_before_or_after_whole_extended_graphemes() {
|
||||
let source = "ae\u{301}👩🚀z\nx👨👩👧👦q\n";
|
||||
for (paste, expected, column) in [
|
||||
('p', "ae\u{301}👩🚀z\nx👨👩👧👦e\u{301}👩🚀q\n", 3),
|
||||
('P', "ae\u{301}👩🚀z\nxe\u{301}👩🚀👨👩👧👦q\n", 2),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "lvly");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
keys(&mut editor, "j0l");
|
||||
let caret = editor.cursor();
|
||||
editor.handle(Input::Char(paste));
|
||||
assert_eq!(editor.context().text, expected, "{paste}");
|
||||
assert_eq!(editor.cursor(), Cursor { row: 1, column });
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
editor.handle(Input::Char(paste));
|
||||
assert_eq!(
|
||||
editor.context().text,
|
||||
expected,
|
||||
"register survives undo and paste"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_keeps_the_pasted_line_shape_without_replacing_the_new_yank_register() {
|
||||
let fixture = Fixture::new();
|
||||
let source = " a\nb\nc";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "yyp");
|
||||
assert_eq!(editor.context().text, " a\n a\nb\nc");
|
||||
keys(&mut editor, "jyy.");
|
||||
assert_eq!(editor.context().text, " a\n a\nb\n a\nc");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, " a\n a\nb\nc");
|
||||
keys(&mut editor, "p");
|
||||
assert_eq!(editor.context().text, " a\n a\nb\nb\nc");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, " a\n a\nb\nc");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_multicarets_yank_matching_whole_lines_and_paste_in_one_undo_step() {
|
||||
let fixture = Fixture::new();
|
||||
let source = " a\n\tb\nc";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "l");
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
let carets = editor.carets();
|
||||
assert_eq!(carets.len(), 2);
|
||||
keys(&mut editor, "yy");
|
||||
assert_eq!(editor.carets(), carets);
|
||||
keys(&mut editor, "p");
|
||||
assert_eq!(editor.context().text, " a\n a\n\tb\n\tb\nc");
|
||||
assert_eq!(
|
||||
editor.carets(),
|
||||
vec![Cursor { row: 1, column: 1 }, Cursor { row: 3, column: 1 }],
|
||||
);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
assert!(!editor.dirty());
|
||||
keys(&mut editor, "P");
|
||||
assert_eq!(editor.context().text, " a\n a\n\tb\n\tb\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_visual_yank_ending_in_a_newline_stays_characterwise() {
|
||||
let fixture = Fixture::new();
|
||||
let source = "\nxy";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "vyj0p");
|
||||
assert_eq!(editor.context().text, "\nx\ny");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
keys(&mut editor, "P");
|
||||
assert_eq!(editor.context().text, "\n\nxy");
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
+548
-5
@@ -16,7 +16,7 @@ use std::{
|
||||
},
|
||||
};
|
||||
use vamp::{
|
||||
diff::{Category, Comparison, FileStatus, RepoKind, Revision, RevisionTarget},
|
||||
diff::{Category, Comparison, ComparisonMode, FileStatus, RepoKind, Revision, RevisionTarget},
|
||||
repository::Repository,
|
||||
};
|
||||
|
||||
@@ -134,7 +134,8 @@ fn compare_loaded(
|
||||
after: &Revision,
|
||||
buffers: &[(PathBuf, String)],
|
||||
) -> Result<Comparison, String> {
|
||||
let mut comparison = repository.compare(1, before, after, buffers)?;
|
||||
let mut comparison =
|
||||
repository.compare(1, before, after, ComparisonMode::Snapshots, buffers)?;
|
||||
for (index, file) in comparison.files.iter_mut().enumerate() {
|
||||
*file = repository.load_file(1, index)?;
|
||||
if file
|
||||
@@ -381,7 +382,13 @@ fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_
|
||||
);
|
||||
assert!(
|
||||
repository
|
||||
.compare(1, &working(RepoKind::Git), &working(RepoKind::Git), &[])
|
||||
.compare(
|
||||
1,
|
||||
&working(RepoKind::Git),
|
||||
&working(RepoKind::Git),
|
||||
ComparisonMode::Snapshots,
|
||||
&[]
|
||||
)
|
||||
.unwrap()
|
||||
.files
|
||||
.is_empty()
|
||||
@@ -574,6 +581,40 @@ fn jj_catalog_and_working_comparisons_prefer_jj_and_never_snapshot_or_merge_oper
|
||||
.iter()
|
||||
.any(|f| f.path == Path::new("ignored-untracked"))
|
||||
);
|
||||
let synthetic_root = revision(&"0".repeat(40), RepoKind::Jujutsu);
|
||||
let branch = repository
|
||||
.compare(
|
||||
20,
|
||||
&synthetic_root,
|
||||
baseline,
|
||||
ComparisonMode::BranchChanges,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(branch.before.ends_with(&"0".repeat(40)));
|
||||
assert!(
|
||||
branch
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path == Path::new("file"))
|
||||
);
|
||||
let reversed = repository
|
||||
.compare(
|
||||
21,
|
||||
baseline,
|
||||
&synthetic_root,
|
||||
ComparisonMode::BranchChanges,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(reversed.files.is_empty());
|
||||
assert!(
|
||||
repository
|
||||
.compare(22, baseline, baseline, ComparisonMode::BranchChanges, &[])
|
||||
.unwrap()
|
||||
.files
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(disk_state(&active), state);
|
||||
}
|
||||
}
|
||||
@@ -657,6 +698,15 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
||||
.iter()
|
||||
.any(|r| r.target == RevisionTarget::Commit(ancestor.clone()))
|
||||
);
|
||||
let local = catalog.entries.iter().find(|revision| {
|
||||
matches!(&revision.target, RevisionTarget::Commit(id) if id.bytes().any(|byte| byte != b'0'))
|
||||
}).unwrap();
|
||||
let branch = repository
|
||||
.compare(60, local, &remote, ComparisonMode::BranchChanges, &[])
|
||||
.unwrap();
|
||||
assert!(branch.before.ends_with(&"0".repeat(40)));
|
||||
assert_eq!(branch.files.len(), 1);
|
||||
assert_eq!(repository.load_file(60, 0).unwrap().after, ["remote tip"]);
|
||||
let comparison =
|
||||
compare_loaded(&mut repository, &remote, &working(RepoKind::Jujutsu), &[]).unwrap();
|
||||
assert_eq!(
|
||||
@@ -682,6 +732,14 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
||||
changed(&comparison, Path::new("file")).after,
|
||||
["remote tip"]
|
||||
);
|
||||
let branch = repository
|
||||
.compare(61, historic, &remote, ComparisonMode::BranchChanges, &[])
|
||||
.unwrap();
|
||||
assert!(branch.before.ends_with(&ancestor));
|
||||
assert_eq!(
|
||||
repository.load_file(61, 0).unwrap().before,
|
||||
["remote ancestor"]
|
||||
);
|
||||
let store = active.join(".jj/repo/store");
|
||||
let active_store =
|
||||
fs::canonicalize(store.join(fs::read_to_string(store.join("git_target")).unwrap().trim()))
|
||||
@@ -747,6 +805,7 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
|
||||
1,
|
||||
&revision("--bad", RepoKind::Git),
|
||||
&working(RepoKind::Git),
|
||||
ComparisonMode::Snapshots,
|
||||
&[]
|
||||
)
|
||||
.unwrap_err()
|
||||
@@ -758,6 +817,7 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
|
||||
1,
|
||||
&revision(&"f".repeat(40), RepoKind::Git),
|
||||
&working(RepoKind::Git),
|
||||
ComparisonMode::Snapshots,
|
||||
&[],
|
||||
)
|
||||
.unwrap_err()
|
||||
@@ -769,7 +829,13 @@ fn errors_are_explicit_and_do_not_modify_active_repository() {
|
||||
};
|
||||
assert!(
|
||||
repository
|
||||
.compare(1, &loader, &revision(&id, RepoKind::Git), &[])
|
||||
.compare(
|
||||
1,
|
||||
&loader,
|
||||
&revision(&id, RepoKind::Git),
|
||||
ComparisonMode::Snapshots,
|
||||
&[]
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("history loader")
|
||||
);
|
||||
@@ -813,6 +879,7 @@ fn unchanged_submodules_are_not_false_changes_and_changed_pointers_are_explicit(
|
||||
1,
|
||||
&revision(&baseline, RepoKind::Git),
|
||||
&working(RepoKind::Git),
|
||||
ComparisonMode::Snapshots,
|
||||
&[]
|
||||
)
|
||||
.unwrap()
|
||||
@@ -1033,6 +1100,29 @@ fn jj_conflicted_revisions_show_cli_projection_and_keep_ordinary_files_navigable
|
||||
assert_eq!(ordinary.before, ["old"]);
|
||||
assert_eq!(ordinary.after, ["left ordinary"]);
|
||||
assert!(ordinary.notice.is_none());
|
||||
let branch = repository
|
||||
.compare(
|
||||
30,
|
||||
&revision(&base, RepoKind::Jujutsu),
|
||||
&revision(&merged, RepoKind::Jujutsu),
|
||||
ComparisonMode::BranchChanges,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let index = branch
|
||||
.files
|
||||
.iter()
|
||||
.position(|file| file.path == Path::new("conflict"))
|
||||
.unwrap();
|
||||
let conflict = repository.load_file(30, index).unwrap();
|
||||
assert_eq!(conflict.after, expected);
|
||||
assert!(
|
||||
conflict
|
||||
.notice
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("conflict projection")
|
||||
);
|
||||
let working_comparison = compare_loaded(
|
||||
&mut repository,
|
||||
&revision(&merged, RepoKind::Jujutsu),
|
||||
@@ -1112,7 +1202,7 @@ fn unsaved_overlays_are_working_after_only_and_cannot_follow_nonregular_paths()
|
||||
);
|
||||
assert!(
|
||||
repository
|
||||
.compare(1, &base, &base, &buffers)
|
||||
.compare(1, &base, &base, ComparisonMode::Snapshots, &buffers)
|
||||
.unwrap()
|
||||
.files
|
||||
.is_empty()
|
||||
@@ -1152,6 +1242,7 @@ fn immutable_catalog_skips_unchanged_blobs_and_prepares_only_requested_details()
|
||||
100,
|
||||
&revision(&before, RepoKind::Git),
|
||||
&revision(&after, RepoKind::Git),
|
||||
ComparisonMode::Snapshots,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1176,6 +1267,7 @@ fn immutable_catalog_skips_unchanged_blobs_and_prepares_only_requested_details()
|
||||
101,
|
||||
&revision(&after, RepoKind::Git),
|
||||
&revision(&after, RepoKind::Git),
|
||||
ComparisonMode::Snapshots,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1198,6 +1290,7 @@ fn deferred_file_details_keep_frozen_checkout_and_unsaved_after() {
|
||||
50,
|
||||
&working(RepoKind::Git),
|
||||
&working(RepoKind::Git),
|
||||
ComparisonMode::Snapshots,
|
||||
&[(root.join("file"), "unsaved after\n".into())],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1211,3 +1304,453 @@ fn deferred_file_details_keep_frozen_checkout_and_unsaved_after() {
|
||||
assert_eq!(again.before, details.before);
|
||||
assert_eq!(again.after, details.after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_text() {
|
||||
let fixture = Fixture::new();
|
||||
let root = fixture.dir("follow").canonicalize().unwrap();
|
||||
init_git(&root);
|
||||
fs::write(root.join("file"), "base\n").unwrap();
|
||||
fs::write(root.join("removed"), "remove me\n").unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "base"]);
|
||||
let base = git(&root, &["rev-parse", "HEAD"]);
|
||||
fs::write(root.join("file"), "original disk\n").unwrap();
|
||||
let mut repository = open(&root);
|
||||
let original = compare_loaded(
|
||||
&mut repository,
|
||||
&revision(&base, RepoKind::Git),
|
||||
&working(RepoKind::Git),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("file"), "later disk\n").unwrap();
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(1, 2, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]);
|
||||
fs::remove_file(root.join("removed")).unwrap();
|
||||
fs::write(root.join("added"), "new file\n").unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "descendant"]);
|
||||
let tip = git(&root, &["rev-parse", "HEAD"]);
|
||||
let state = disk_state(&root);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(99, 2, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let mut refreshed = repository
|
||||
.refresh_working_after(1, 2, &[(root.join("file"), "unsaved\n".into())])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(refreshed.before, original.before);
|
||||
assert_eq!(refreshed.after, original.after);
|
||||
for (index, file) in refreshed.files.iter_mut().enumerate() {
|
||||
*file = repository.load_file(2, index).unwrap();
|
||||
}
|
||||
assert_eq!(changed(&refreshed, Path::new("file")).before, ["base"]);
|
||||
assert_eq!(changed(&refreshed, Path::new("file")).after, ["unsaved"]);
|
||||
assert_eq!(
|
||||
changed(&refreshed, Path::new("added")).status,
|
||||
FileStatus::Added
|
||||
);
|
||||
assert_eq!(
|
||||
changed(&refreshed, Path::new("removed")).status,
|
||||
FileStatus::Removed
|
||||
);
|
||||
assert!(refreshed.editable_paths.contains(Path::new("file")));
|
||||
assert!(refreshed.editable_paths.contains(Path::new("added")));
|
||||
assert!(!refreshed.editable_paths.contains(Path::new("removed")));
|
||||
assert!(repository.load_file(1, 0).is_err());
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(2, 3, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(disk_state(&root), state);
|
||||
git(&root, &["checkout", "--detach", &base]);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(2, 3, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
git(&root, &["checkout", "-b", "other"]);
|
||||
fs::write(root.join("file"), "other branch\n").unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "unrelated to tip"]);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(2, 3, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let index = refreshed
|
||||
.files
|
||||
.iter()
|
||||
.position(|file| file.path == Path::new("file"))
|
||||
.unwrap();
|
||||
assert_eq!(repository.load_file(2, index).unwrap().after, ["unsaved"]);
|
||||
git(&root, &["checkout", "--detach", &tip]);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(2, 3, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure() {
|
||||
let fixture = Fixture::new();
|
||||
let root = fixture.dir("frozen-follow").canonicalize().unwrap();
|
||||
init_git(&root);
|
||||
fs::write(root.join("file"), "base\n").unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "base"]);
|
||||
let base = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git);
|
||||
fs::write(root.join("file"), "frozen before\n").unwrap();
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let mut repository = Repository::open(&root, cancelled.clone()).unwrap();
|
||||
repository
|
||||
.compare(
|
||||
10,
|
||||
&working(RepoKind::Git),
|
||||
&working(RepoKind::Git),
|
||||
ComparisonMode::Snapshots,
|
||||
&[(root.join("file"), "frozen after\n".into())],
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("file"), "next checkout\n").unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "next"]);
|
||||
cancelled.store(true, Ordering::Release);
|
||||
assert!(repository.refresh_working_after(10, 11, &[]).is_err());
|
||||
cancelled.store(false, Ordering::Release);
|
||||
assert_eq!(repository.load_file(10, 0).unwrap().after, ["frozen after"]);
|
||||
fs::write(root.join("oversized"), vec![b'x'; 17 * 1024 * 1024]).unwrap();
|
||||
assert!(repository.refresh_working_after(10, 11, &[]).is_err());
|
||||
let original = repository.load_file(10, 0).unwrap();
|
||||
assert_eq!(original.before, ["frozen before"]);
|
||||
assert_eq!(original.after, ["frozen after"]);
|
||||
assert!(repository.load_file(11, 0).is_err());
|
||||
fs::remove_file(root.join("oversized")).unwrap();
|
||||
let refreshed = repository
|
||||
.refresh_working_after(10, 11, &[])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(refreshed.files.len(), 1);
|
||||
let file = repository.load_file(11, 0).unwrap();
|
||||
assert_eq!(file.before, ["frozen before"]);
|
||||
assert_eq!(file.after, ["next checkout"]);
|
||||
let tip = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git);
|
||||
repository
|
||||
.compare(12, &base, &tip, ComparisonMode::Snapshots, &[])
|
||||
.unwrap();
|
||||
fs::write(root.join("file"), "newer checkout\n").unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "newer"]);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(12, 13, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
repository.load_file(12, 0).unwrap().after,
|
||||
["next checkout"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_materialization_failure_restores_prepared_file_sources() {
|
||||
let fixture = Fixture::new();
|
||||
let root = fixture.dir("failed-materialization");
|
||||
init_git(&root);
|
||||
fs::write(root.join("a-file"), "frozen source\n").unwrap();
|
||||
fs::write(root.join("z-large"), vec![b'x'; 17 * 1024 * 1024]).unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "base"]);
|
||||
let base = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git);
|
||||
fs::remove_file(root.join("a-file")).unwrap();
|
||||
fs::remove_file(root.join("z-large")).unwrap();
|
||||
let mut repository = open(&root);
|
||||
let original = repository
|
||||
.compare(
|
||||
1,
|
||||
&base,
|
||||
&working(RepoKind::Git),
|
||||
ComparisonMode::Snapshots,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let index = original
|
||||
.files
|
||||
.iter()
|
||||
.position(|file| file.path == Path::new("a-file"))
|
||||
.unwrap();
|
||||
fs::write(root.join("a-file"), "new checkout\n").unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "descendant"]);
|
||||
assert!(repository.refresh_working_after(1, 2, &[]).is_err());
|
||||
let file = repository.load_file(1, index).unwrap();
|
||||
assert_eq!(file.before, ["frozen source"]);
|
||||
assert!(file.after.is_empty());
|
||||
assert_eq!(file.status, FileStatus::Removed);
|
||||
assert!(repository.load_file(2, index).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unborn_git_working_after_safely_advances_to_its_first_commit() {
|
||||
let fixture = Fixture::new();
|
||||
let root = fixture.dir("unborn");
|
||||
init_git(&root);
|
||||
let mut repository = open(&root);
|
||||
repository
|
||||
.compare(
|
||||
1,
|
||||
&working(RepoKind::Git),
|
||||
&working(RepoKind::Git),
|
||||
ComparisonMode::Snapshots,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(1, 2, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
fs::write(root.join("first"), "initial\n").unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "initial"]);
|
||||
let comparison = repository
|
||||
.refresh_working_after(1, 2, &[])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(comparison.files.len(), 1);
|
||||
assert_eq!(repository.load_file(2, 0).unwrap().after, ["initial"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
|
||||
for colocate in [false, true] {
|
||||
let fixture = Fixture::new();
|
||||
let root = fixture.dir("jj-follow").canonicalize().unwrap();
|
||||
init_jj(&root, colocate);
|
||||
fs::write(root.join("file"), "base\n").unwrap();
|
||||
fs::write(root.join("removed"), "remove me\n").unwrap();
|
||||
jj(&root, &["describe", "-m", "base"]);
|
||||
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
jj(&root, &["new"]);
|
||||
fs::write(root.join("file"), "original disk\n").unwrap();
|
||||
let mut repository = open(&root);
|
||||
compare_loaded(
|
||||
&mut repository,
|
||||
&revision(&base, RepoKind::Jujutsu),
|
||||
&working(RepoKind::Jujutsu),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("file"), "same-change rewrite\n").unwrap();
|
||||
jj(&root, &["describe", "-m", "rewritten"]);
|
||||
let state = disk_state(&root);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(1, 2, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]);
|
||||
assert_eq!(disk_state(&root), state);
|
||||
jj(&root, &["new"]);
|
||||
fs::remove_file(root.join("removed")).unwrap();
|
||||
fs::write(root.join("added"), "new file\n").unwrap();
|
||||
let state = disk_state(&root);
|
||||
let mut comparison = repository
|
||||
.refresh_working_after(1, 2, &[(root.join("file"), "unsaved\n".into())])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
for (index, file) in comparison.files.iter_mut().enumerate() {
|
||||
*file = repository.load_file(2, index).unwrap();
|
||||
}
|
||||
assert_eq!(changed(&comparison, Path::new("file")).before, ["base"]);
|
||||
assert_eq!(changed(&comparison, Path::new("file")).after, ["unsaved"]);
|
||||
assert_eq!(
|
||||
changed(&comparison, Path::new("added")).status,
|
||||
FileStatus::Added
|
||||
);
|
||||
assert_eq!(
|
||||
changed(&comparison, Path::new("removed")).status,
|
||||
FileStatus::Removed
|
||||
);
|
||||
assert!(repository.load_file(1, 0).is_err());
|
||||
assert_eq!(disk_state(&root), state);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(2, 3, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
jj(&root, &["edit", &base]);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(2, 3, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
jj(&root, &["new", &base]);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(2, 3, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let index = comparison
|
||||
.files
|
||||
.iter()
|
||||
.position(|file| file.path == Path::new("file"))
|
||||
.unwrap();
|
||||
assert_eq!(repository.load_file(2, index).unwrap().after, ["unsaved"]);
|
||||
repository
|
||||
.compare(
|
||||
10,
|
||||
&working(RepoKind::Jujutsu),
|
||||
&working(RepoKind::Jujutsu),
|
||||
ComparisonMode::Snapshots,
|
||||
&[(root.join("file"), "frozen after\n".into())],
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("file"), "new checkout\n").unwrap();
|
||||
jj(&root, &["new"]);
|
||||
let refreshed = repository
|
||||
.refresh_working_after(10, 11, &[])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(refreshed.files.len(), 1);
|
||||
let file = repository.load_file(11, 0).unwrap();
|
||||
assert_eq!(file.before, ["base"]);
|
||||
assert_eq!(file.after, ["new checkout"]);
|
||||
let historical = revision(&base, RepoKind::Jujutsu);
|
||||
repository
|
||||
.compare(12, &historical, &historical, ComparisonMode::Snapshots, &[])
|
||||
.unwrap();
|
||||
jj(&root, &["new"]);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(12, 13, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_changes_resolves_local_and_private_remote_ancestry_without_active_mutation() {
|
||||
let fixture = Fixture::new();
|
||||
let active = fixture.dir("active\nquoted\"\\");
|
||||
init_git(&active);
|
||||
fs::write(active.join("common"), "base\n").unwrap();
|
||||
git(&active, &["add", "."]);
|
||||
git(&active, &["commit", "-m", "base"]);
|
||||
let base = git(&active, &["rev-parse", "HEAD"]);
|
||||
let source = fixture.0.join("source");
|
||||
git(
|
||||
&fixture.0,
|
||||
&["clone", active.to_str().unwrap(), source.to_str().unwrap()],
|
||||
);
|
||||
git(&source, &["config", "user.name", "Diff Test"]);
|
||||
git(&source, &["config", "user.email", "diff@example.invalid"]);
|
||||
fs::write(source.join("remote-only"), "remote\n").unwrap();
|
||||
git(&source, &["add", "."]);
|
||||
git(&source, &["commit", "-m", "remote"]);
|
||||
fs::write(active.join("local-only"), "local\n").unwrap();
|
||||
git(&active, &["add", "."]);
|
||||
git(&active, &["commit", "-m", "local"]);
|
||||
let local = revision(&git(&active, &["rev-parse", "HEAD"]), RepoKind::Git);
|
||||
git(
|
||||
&active,
|
||||
&["remote", "add", "origin", source.to_str().unwrap()],
|
||||
);
|
||||
let state = disk_state(&active);
|
||||
let mut repository = open(&active);
|
||||
let catalog = repository.list_remote("origin").unwrap();
|
||||
let remote = catalog
|
||||
.entries
|
||||
.iter()
|
||||
.find(|r| matches!(r.target, RevisionTarget::Remote { .. }))
|
||||
.unwrap();
|
||||
let branch = repository
|
||||
.compare(40, &local, remote, ComparisonMode::BranchChanges, &[])
|
||||
.unwrap();
|
||||
assert_eq!(branch.files.len(), 1);
|
||||
assert_eq!(branch.files[0].path, Path::new("remote-only"));
|
||||
assert!(branch.before.ends_with(&base));
|
||||
assert_eq!(repository.load_file(40, 0).unwrap().after, ["remote"]);
|
||||
let reverse = repository
|
||||
.compare(41, remote, &local, ComparisonMode::BranchChanges, &[])
|
||||
.unwrap();
|
||||
assert_eq!(reverse.files.len(), 1);
|
||||
assert_eq!(reverse.files[0].path, Path::new("local-only"));
|
||||
assert_eq!(repository.load_file(41, 0).unwrap().after, ["local"]);
|
||||
assert!(
|
||||
repository
|
||||
.compare(42, &local, &local, ComparisonMode::BranchChanges, &[])
|
||||
.unwrap()
|
||||
.files
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(disk_state(&active), state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_changes_rejects_missing_and_multiple_merge_bases_without_snapshot_fallback() {
|
||||
let fixture = Fixture::new();
|
||||
let root = fixture.dir("graphs");
|
||||
init_git(&root);
|
||||
fs::write(root.join("file"), "base\n").unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "base"]);
|
||||
let base = git(&root, &["rev-parse", "HEAD"]);
|
||||
let tree = git(&root, &["rev-parse", "HEAD^{tree}"]);
|
||||
let left = git(&root, &["commit-tree", &tree, "-p", &base, "-m", "left"]);
|
||||
let right = git(&root, &["commit-tree", &tree, "-p", &base, "-m", "right"]);
|
||||
let one = git(
|
||||
&root,
|
||||
&["commit-tree", &tree, "-p", &left, "-p", &right, "-m", "one"],
|
||||
);
|
||||
let two = git(
|
||||
&root,
|
||||
&["commit-tree", &tree, "-p", &right, "-p", &left, "-m", "two"],
|
||||
);
|
||||
let unrelated = git(&root, &["commit-tree", &tree, "-m", "unrelated"]);
|
||||
let state = disk_state(&root);
|
||||
let mut repository = open(&root);
|
||||
for (before, after, message) in [
|
||||
(&base, &unrelated, "no merge base"),
|
||||
(&one, &two, "multiple merge bases"),
|
||||
] {
|
||||
let before = revision(before, RepoKind::Git);
|
||||
let after = revision(after, RepoKind::Git);
|
||||
assert!(
|
||||
repository
|
||||
.compare(50, &before, &after, ComparisonMode::BranchChanges, &[])
|
||||
.unwrap_err()
|
||||
.contains(message)
|
||||
);
|
||||
assert!(
|
||||
repository
|
||||
.compare(51, &before, &after, ComparisonMode::Snapshots, &[])
|
||||
.unwrap()
|
||||
.files
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
assert_eq!(disk_state(&root), state);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user