Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4099435175 | |||
| a8428f9dde | |||
| cd427f74dc | |||
| 11b31d2621 | |||
| adcc4f2656 | |||
| 2357b5be46 | |||
| e2f33519d1 | |||
| b58fb085a3 | |||
| 00628e0efd | |||
| 142b00118a | |||
| 9526876b04 | |||
| e90d1c3945 | |||
| fd59c05f80 | |||
| 55cdee1a13 | |||
| e84d70463f | |||
| 983f43e710 | |||
| 99fa5a2aff | |||
| e3e087f16f | |||
| 33942b2671 | |||
| 0eec3fc3b9 | |||
| 9d6a0eaeca |
@@ -37,7 +37,7 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal.
|
||||
| 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 |
|
||||
| Repository diff in Normal | `:diff`; choose Before then After, Tab changes category, type to search; Esc closes the selector; Working or checkout-matched pinned After is editable; `:diff.close` exits the comparison |
|
||||
| Diff layout in Normal | Space then `v` cycles split/unified; Space then `s` flips left/right vs above/below split |
|
||||
|
||||
Space is the default leader: press its suffix keys in order, not simultaneously. The [editing guide](docs/editing.md) contains the complete key table and behavior reference.
|
||||
|
||||
@@ -1,36 +1,23 @@
|
||||
- [x] navigation control/actions: seek occurrence of character ('f' forwards, 'F' backwards by default; allow e.g. `2f` for seeking second occurrence instead of first).
|
||||
- [x] buffer ux improvement: enable mouse scrolling sideways so longer lines are easier to follow and read (e.g. in case they extend beyond the width of the buffer view).
|
||||
- [x] buffer ux improvement: add toggleable word wrap (no keybinding by default).
|
||||
- [x] add panes/splits: split current pane above/below (`_`) or left/right (`|`) in Normal mode; navigate panes with Option/Alt+h/j/k/l.
|
||||
- shared split layout and directional focus with diff views; independent pane views, shared buffers, and guarded close/force-close actions.
|
||||
- [x] remove diff viewer's file explorer's `A added M modified...` explanation - it's standard and the explanation doesn't fit in the pane, so it can go.
|
||||
- no replacement help footer in diff file lists; reclaim its row for file entries.
|
||||
- [x] diff view: add character-wise change highlight (enabled by default).
|
||||
- [x] add tabs: tab support, editor action to open a buffer in a new tab, default tab navigation is `ctrl+option+h/l`, close tab action.
|
||||
- per-tab pane layouts, views, explorers, and comparisons; shared buffers, tab bar, and guarded close/force-close actions.
|
||||
- [x] make diff selector close on escape.
|
||||
- [x] `:` currently overrides the diff selector input (and maybe other input fields) and spawns the command input on top. this is of course not intended behavior and should be fixed.
|
||||
- [x] diff view: lsp actions should act as they normally would. if an lsp action would open another file, then the file should be opened in another tab outside the diff view - if the referenced file/symbol is part of the changes in the diff, it should just be opened in the diff view.
|
||||
- changed symbol ranges stay in the comparison; other targets open ordinary tabs. Historical text stays immutable; source freshness and failure atomicity are preserved.
|
||||
- [x] better syntax highlighting for markdown files. color `-` and `1.` from lists.
|
||||
- [x] automatically reload buffers when underlying file changes outside of vamp if buffer is clean. if buffer is dirty, reject normal write attempts with warning and require forceful write to overwrite underlying file contents.
|
||||
- [x] diff view: change diff view modes; choose between split and unified diff view (split by default; some key cycles; default should be configurable).
|
||||
- Space v cycles split/unified; configurable startup default, dual line numbers, native side focus/editing, and shared highlighting/wrap/excerpts.
|
||||
- [x] diff view: for split view, change split orientation (left/right vs above/below) - left/right by default.
|
||||
- Space s flips orientation; configurable startup default and shared pane geometry/directional focus.
|
||||
- [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.
|
||||
- [x] add option to highlight trailing whitespace (off by default)
|
||||
- [x] add option to put characters at certain whitespace characters (off by default)
|
||||
- whitespace kind -> character map should be configurable.
|
||||
- [x] diff view: use two-column diagonal hatching for missing counterparts (45° with 2:1 terminal cells)
|
||||
- [ ] github/gitlab/gitea integration (probably requries authentication with github)
|
||||
- github pr picker (floating window)
|
||||
- three tabs: open, closed, all
|
||||
- search under the given category
|
||||
- selecting a pr opens the diff viewer for that pr using the branch merge base (standard github diff view on prs; already implemented as default diff viewer behavior in vamp)
|
||||
- [ ] cut down on the amount of documentation in the project (readme file is needlessly detailed and filled with irrelevant information).
|
||||
- [ ] 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.
|
||||
- [ ] pull a pr description and display it in a buffer:
|
||||
- open a selector that allows you to pick a pr from remote
|
||||
- selector should be searchable
|
||||
- selected pr description is opened in a separate buffer/tab.
|
||||
- open related pr description directly from diff view (should be able to figure out the relevant pr from the branch involved in the diff - if jj, then from tracked bookmark; if just git, then from branch)
|
||||
- [ ] sandboxed, revision-specific LSP analysis for historical diff states and historical tabs.
|
||||
- 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.
|
||||
@@ -38,15 +25,4 @@
|
||||
- enforce an OS-level sandbox on the LSP process and every child it spawns, including build scripts and procedural macros. A temporary checkout and read-only editor buffers are not security isolation.
|
||||
- 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)
|
||||
- retain the snapshot and analysis environment while historical views/tabs reference it; stop workers and clean up disposable storage after the final reference closes.
|
||||
+10
-4
@@ -13,7 +13,7 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
|
||||
- `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.
|
||||
Virtual diff leaves retain their original catalog input index for caller-owned annotations. Normal scanned leaves accept optional repository status and old-path metadata without filtering unchanged files; missing tracked deletions add virtual leaves and required directories. Metadata-only updates preserve query, selection, expansion, and view without rebuilding the catalog.
|
||||
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
|
||||
- `repository`: discovers jj/Git repositories and local/advertised remote revisions, builds disk snapshots and changed-file hunks, and owns isolated temporary comparison repositories with bounded, cancellable subprocesses. It never saves editor buffers or modifies active refs/checkouts.
|
||||
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
|
||||
@@ -53,7 +53,7 @@ Frontends borrow `lsp_label()` / `lsp_names()` for the focused buffer's cached r
|
||||
|
||||
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 `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion, optional repository status and old path). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors and sanitized rename labels, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
|
||||
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
|
||||
Frontends borrow `semantic_tokens()` for sorted, single-line grapheme spans from the active buffer. Immutable `Arc` slices are shared by native LSP and buffer storage; painting does not copy tokens. Text/path changes discard overlays, and native updates validate the whole applicable annotation batch before mutation. The TUI merges semantic styling into its existing glyph pass above syntax foregrounds but below diagnostic/search/selection/caret styling.
|
||||
Frontends inspect `hover()` for optional read-only content and logical-line/grapheme scroll offsets. Popup request eligibility, dismissal, and scrolling stay editor/session-owned; the TUI clips the panel inside the full editor area without changing the primary cursor or viewport. Its hover-local syntax cache treats content as Markdown, including named fenced-code injections, and rebuilds only when content changes. The popup shares the document syntax palette and preserves raw Markdown text and logical scroll coordinates.
|
||||
@@ -78,13 +78,19 @@ Comparison views reserve no separate metadata header or native-buffer help foote
|
||||
|
||||
Each comparison owns its split/unified mode and split axis; startup defaults apply only when opening a comparison. Source alignment and file-side availability stay independent of presentation. Unified display rows carry a side projection: equal context maps both sources, changed runs emit Before rows followed by After rows, and folds retain aligned coordinates. Native navigation/editing continues to bind source buffers rather than a synthetic unified document. The TUI reuses side-specific syntax/wrap/overlay pipelines in one unified region or the existing axis-based split geometry, exposing only the active logical side for unified hit-testing and directional focus.
|
||||
|
||||
`Editor::scroll_distance()` supplies the session-wide optional edge margin. The TUI applies it to document/diff visual rows and unwrapped text columns, and retains independent picker offsets across navigation frames. Picker windows exclude their fixed chrome and align whole multi-row entries. File projections carry a monotonic viewport revision, so catalog/filter/expansion changes invalidate picker identity without hashing the entire catalog on every redraw. Metadata-only status changes do not move the viewport. Explicit LSP/wrap-toggle anchors and detached wheel views retain precedence.
|
||||
|
||||
Mouse wheel input uses the last rendered document/panel rectangles for hit-testing. Document offsets and explorer scroll state remain frontend-owned; three-row wheel steps never become document cursor motions. Manual document views persist until logical caret-reveal, caret/selection, mode, revision, or buffer changes; offscreen primary and browsing-selection cursors are not placed. Hover wheel input reuses the existing editor-owned popup scrolling.
|
||||
|
||||
Already-ready mouse reports share a redraw: at most 64 reports are applied in their original order before painting, without dropping or summing away wheel steps. Readiness checks do not wait. The first non-mouse event is retained for the next iteration after rendering, so keys, paste, and resizes cannot reuse geometry from before their layout changes. The batch bound keeps continuous mouse input from starving drawing or background polling.
|
||||
|
||||
Ratatui's bounded layout cache is enabled: unchanged geometry and constraints reuse solved rectangles during scrolling; resizes and panel-layout changes use their own cache keys.
|
||||
|
||||
The buffer viewport includes a fixed left gutter with right-aligned hybrid line numbers: the highlighted primary cursor line shows its one-based absolute number; every other line shows its distance from the primary. Secondary carets do not establish separate numbering origins. Gutter width follows the total line count plus one separator cell, including an empty final row after a trailing newline. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Rows beyond the buffer have no number. The gutter is hidden when it would leave no text column.
|
||||
The buffer viewport includes a fixed left gutter with right-aligned hybrid line numbers: the highlighted primary cursor line shows its one-based absolute number; every other line shows its distance from the primary. Secondary carets do not establish separate numbering origins. Width follows the total line count plus one separator cell, including an empty final row after a trailing newline; `git_gutter` bar/symbol modes add a blank cell and a marker cell between the number and separator. Number-color/none modes add no width. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Wrapped continuations and rows beyond the buffer have no number or marker. The gutter is hidden when it would leave no text column.
|
||||
|
||||
Repository gutter work runs off the render/editor thread. Revision/path-validated worker results update marker vectors belonging to ordinary source buffers, not historical snapshots; panes and tabs borrow their own buffer's markers. After text edits, the last completed markers for the same path remain visible until a current-revision result replaces them, rather than disappearing during each asynchronous refresh. The worker compares editor text with Git HEAD or Git-backed jj's single parent, independently of save baselines and index state. The frontend paints green additions, red deletion anchors, and yellow modifications as `▌`, symbols, or number colors; current-line cyan/bold overrides repository colors in number-color mode.
|
||||
|
||||
The same cancellable worker optionally collects normal-picker status, including when line-gutter rendering is disabled. Disk metadata and dirty-buffer overlays use Git HEAD or jj's single parent without snapshotting jj or writing active refs, objects, or indexes. A private baseline index retains its stat cache across polls; conflicted jj parents are projected once per root/parent. Root, baseline, store, or builtin Git conversion-setting changes invalidate that cache. Private queries disable user filters, external diff, and textconv. Request/tab/root checks reject stale picker results; unchanged catalogs retain their ordinary scanner and filtering rules.
|
||||
|
||||
## Syntax caches
|
||||
|
||||
@@ -100,7 +106,7 @@ macOS publishes `public.utf8-plain-text` through the native Pasteboard Manager,
|
||||
|
||||
The native module initializes each server, sends `initialization_options` as `initializationOptions`, answers `workspace/configuration` from `settings` (including dotted sections; missing sections return `null`), honors open/change/save capabilities, synchronizes in-memory text including hidden dirty buffers, and sends close events. Incremental servers receive whole-document range replacements. Process/pipe I/O and requests stay on bounded background workers; input and idle diagnostic redraw do not wait for server responses.
|
||||
|
||||
Diff snapshots retain a separate immutable document identity, full source text, side-specific path (the old path for renamed Before), and UTF-16 positions. Native definition updates preserve their UTF-16 ranges until the editor resolves the target's actual snapshot, dirty buffer, disk text, or lazily loaded comparison file; generic plugin navigation remains grapheme-based. Target ranges overlapping actual changed rows route within the same comparison and reveal the exact selection, including an unchanged declaration above a changed body. Wholly unchanged/context/omitted targets and files outside its changes open an ordinary tab without changing the original comparison or view. Snapshot-URI targets keep their immutable identity. Freshness includes tab, pane, source buffer/revision/caret/mode, and comparison/side; deferred file-load navigation also checks its originating view before moving focus. Invalid targets fail before opening a tab. Completion remains restricted to writable Working After.
|
||||
Diff snapshots retain a separate immutable document identity, full source text, side-specific path (the old path for renamed Before), and UTF-16 positions. Native definition updates preserve their UTF-16 ranges until the editor resolves the target's actual snapshot, dirty buffer, disk text, or lazily loaded comparison file; generic plugin navigation remains grapheme-based. Target ranges overlapping actual changed rows route within the same comparison and reveal the exact selection, including an unchanged declaration above a changed body. Wholly unchanged/context/omitted targets and files outside its changes open an ordinary tab without changing the original comparison or view. Snapshot-URI targets keep their immutable identity. Freshness includes tab, pane, source buffer/revision/caret/mode, and comparison/side; deferred file-load navigation also checks its originating view before moving focus. Invalid targets fail before opening a tab. Completion remains restricted to writable After, whether Working or a checkout-matched pinned endpoint.
|
||||
|
||||
Language configurations can own an ordered server stack. The first matching language wins extension overlap; all its matching servers receive document events, while the first handles hover/definition/completion. The Python preset uses ty then Ruff. LSP merges each worker's latest diagnostic snapshot in assignment order before emitting the unchanged core-owned update. Empty publications/retirement remove only their source; revision/path/root changes and close discard all contributions. Worker snapshots retain identity so queued pre-rename diagnostics cannot attach to a new path with the same text revision. Combined diagnostic message bytes remain bounded to 4 MiB; an overflowing publication retires its worker instead of discarding healthy sibling annotations. Workers are stored in config/root order, avoiding per-poll key cloning/sorting. Shutdown flushes the exit notification and waits for peer termination within the existing bounded grace period before process cleanup.
|
||||
|
||||
|
||||
+33
-5
@@ -14,6 +14,11 @@ Vamp.configure do |config|
|
||||
config.leader = "space"
|
||||
config.clipboard_mode = "system"
|
||||
config.git_blame = true # false disables current-line attribution
|
||||
config.git_gutter = "bar" # or "symbols", "line_numbers", "none"
|
||||
config.scroll_distance = 0 # nonnegative edge distance, or nil to center
|
||||
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
|
||||
config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\n" => "↵" }
|
||||
config.whitespace_scope = "all" # or "leading" for indentation only
|
||||
config.file_explorer_side = "right"
|
||||
config.file_explorer_view = "tree"
|
||||
config.file_picker_view = "tree"
|
||||
@@ -46,13 +51,35 @@ 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.scroll_distance` defaults to `0`: keep the viewport still until its primary cursor or selected picker entry would leave it. A nonnegative integer reserves that many visible rows above/below the cursor, and terminal-cell columns left/right in unwrapped text. `nil` keeps it centered; integers at least half the current viewport dimension behave the same way until resizing makes that margin usable. The start of content limits centering, while trailing blank space allows centering near the end. Whole two-row picker entries stay aligned, using the nearest entry to the center when exact margins cannot fit.
|
||||
|
||||
The setting applies to ordinary and historical buffers, split/unified diff panes, file pickers and explorers, command/buffer palettes, completion lists, and revision selectors. Wrapped text measures visual rows, not source lines; picker distances use result rows, excluding borders, search fields, and help. Manual mouse-wheel scrolling remains detached until keyboard navigation resumes. Only `nil` or integers from `0` through the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems) are accepted; other values reject the configuration.
|
||||
|
||||
`config.highlight_trailing_whitespace` defaults to `false`. Set it to `true` to give trailing whitespace a red background in ordinary and historical buffers and both sides of split/unified diffs, including wrapped and horizontally scrolled text. Spaces, expanded tab cells, and Unicode whitespace at the end of a source line are highlighted; whitespace-only lines are included, but empty lines and padding beyond the text are not. Wrapping does not make interior whitespace trailing. Search, selections, and secondary carets retain their background precedence; diagnostic underlines remain visible. This changes display only, not file contents. Only literal Ruby booleans are accepted; other values reject the configuration.
|
||||
|
||||
`config.whitespace_characters` defaults to `{}` (disabled). Assign a hash mapping literal whitespace characters to dim display markers, for example `{ " " => "·", "\t" => "→", "\u00a0" => "⍽", "\n" => "↵" }` for spaces, tabs, nonbreaking spaces, and line breaks. Unlisted characters keep their ordinary appearance. Keys and values must be single-character strings; keys must be whitespace with visible terminal width, tabs, or `"\n"`, and values must be printable one-cell characters. Carriage returns and other control characters, zero-width markers, and wide markers reject the configuration. Only indicator cells receive terminal dim styling; source-text colors, diagnostic underlines, and highlighted backgrounds remain intact.
|
||||
|
||||
`config.whitespace_scope` defaults to `"all"`. Use `"leading"` to mark only the initial whitespace prefix of each source line; a whitespace-only line is entirely leading, but wrapping and horizontal scrolling do not create new indentation. Only the strings `"all"` and `"leading"` are accepted; other values reject the configuration.
|
||||
|
||||
A `"\n"` map entry marks actual line breaks independently of `whitespace_scope`, including blank lines and a file's final newline when present. Soft wraps, missing final newlines, and the virtual empty EOF row do not gain markers. The glyph occupies the existing end-of-line caret cell on the final wrapped segment; a full-width line uses the existing extra caret segment. In unwrapped text, an offscreen end-of-line marker is clipped like other source cells. Newline markers inside syntax-recognized strings are suppressed along with horizontal whitespace markers.
|
||||
|
||||
Whitespace inside strings recognized by the bundled syntax grammar stays literal, including raw/multiline strings, heredocs, interpolated strings, and strings with embedded-language highlighting. Outside strings, configured markers still apply. String boundaries come from the original source, so wrapping, horizontal scrolling, and split/unified historical diffs retain this distinction. Plain text and unknown languages have no syntax-defined string boundaries; quotes are not guessed to be strings.
|
||||
|
||||
Markers appear in ordinary and historical buffers and both sides of split/unified diffs. Tabs and wide Unicode whitespace show the marker in their first source cell and leave remaining cells blank, preserving tab stops, wrapping, horizontal scrolling, and cursor/selection geometry. A clipped first cell hides its marker; padding beyond the source does not gain markers. Whitespace with combining characters remains intact. Syntax colors, search, selections, diagnostics, and trailing-whitespace highlighting still apply. This changes display only: editing, copying, and saving retain the original characters.
|
||||
|
||||
`config.git_blame` defaults to `true`; set it to `false` to disable current-line inline Git blame and its background worker. Only literal Ruby booleans are accepted; other values reject the configuration. Toggle it for the running session with `:git_blame.toggle`, its palette action, or a custom binding such as `config.bind "leader b", config.core.git_blame.toggle`. There is no default shortcut.
|
||||
|
||||
`config.git_gutter` defaults to `"bar"`: a fat `▌` with one blank column between the line number and marker and another before the text. `"symbols"` uses `+` / `-` / `~` with the same spacing. Added, removed, and modified lines use green, red, and yellow respectively. `"line_numbers"` colors the existing numbers without adding a marker column; the current line retains its cyan/bold caret highlight. `"none"` hides line-gutter repository indicators, not line numbers or picker file statuses. Other modes retain diagnostic number colors independently of repository markers. Unsupported strings, empty strings, and non-string values reject the configuration.
|
||||
|
||||
In `"line_numbers"` mode, color precedence is **current-line cyan/bold → highest-severity diagnostic → repository change → default number color**. In bar/symbol modes, diagnostic number colors and repository markers remain independent.
|
||||
|
||||
Indicators compare the current editor text (including unsaved edits) against Git `HEAD`, or the parent `@-` in Git-backed jj repositories, rather than against the last save or Git index. Line endings use the editor's LF normalization. They refresh after edits/saves, on focus regain, and once per second for repository changes. Historical snapshots do not receive live indicators. Eligible named files not yet on disk show nonempty unsaved text as added; tracked files missing on disk still compare their buffer text against the baseline. Non-repository, ignored, binary, symlink, and unsupported files have no indicators; neither do jj working changes with multiple parents. Both colocated and non-colocated Git-backed jj repositories are supported. Files above 16 MiB report an error and clear their indicators; request batches are limited to 64 MiB or 1024 files.
|
||||
|
||||
`config.diff_context_lines = 10` sets how many unchanged aligned lines appear above and below each diff change; it defaults to `10` and accepts nonnegative integers, including `0` for changes only. `config.diff_expand_lines = 15` sets how many additional lines each directional expansion reveals per keypress; it defaults to `15` and accepts positive integers. Both are bounded by the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems). Floats, strings, booleans, negative or oversized values, and a zero expansion step reject the whole Ruby configuration.
|
||||
|
||||
`config.diff_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.
|
||||
`config.diff_follow_working_copy` defaults to `true`. A Working-checkout After follows descendant checkouts automatically, including in inactive tabs; `false` disables endpoint following without disabling ordinary live-buffer editing or reload. Only literal booleans are accepted. Pinned After endpoints never move, but their checkout-aware editability checks remain enabled regardless of this setting. See [repository diff](editing.md#repository-diff) for ancestry and refresh behavior.
|
||||
|
||||
```ruby
|
||||
config.bind "ctrl+b", config.core.diff.mode.toggle
|
||||
@@ -156,9 +183,9 @@ The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visua
|
||||
|
||||
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 `yy` copies complete rows without moving the carets; `dd` / `cc` delete/change complete rows; `dw` / `db` delete forward/backward word spans within each caret's current logical line, preserving newlines. `cw` / `cb` cut the same spans and enter Insert. Leading and post-operator counts multiply and word edits stop at the line end/start. Bind `config.core.copy_line`, `config.core.delete_line`, `config.core.change_line`, `config.core.delete_word_forward`, `config.core.delete_word_backward`, `config.core.change_word_forward`, or `config.core.change_word_backward` to another key or sequence for another route. A configured/disabled operator key, a configured sequence starting with that key, or choosing it as the leader overrides its built-in operator prefix. These are dedicated actions, not general operator–motion grammar.
|
||||
|
||||
Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through `config.core.change_to_line_end`, `config.core.delete_to_line_end`, and `config.core.copy_to_line_end`. Normal `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.
|
||||
Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through `config.core.change_to_line_end`, `config.core.delete_to_line_end`, and `config.core.copy_to_line_end`. Normal and Visual `r` wait for a literal replacement character through `config.core.replace_character`: Normal honors the count; Visual replaces each selected grapheme without replacing newlines. Normal `s` deletes the current grapheme and enters Insert through `config.core.substitute_character`. Rebinding these actions preserves their native counts, Unicode boundaries, undo, and read-only guards.
|
||||
|
||||
`f<char>` / `F<char>` seeks forward/backward on the current line in Normal and Visual, with counts such as `2fx`. Rebind `config.core.seek_forward` / `config.core.seek_backward` to a single key or leader sequence; the action then waits for a literal target character. For example, `config.bind "s", config.core.seek_forward` makes `2sx` seek the second following `x`. Pass `mode: config.modes.visual` to install a Visual route. These native-only actions do not have Ex commands.
|
||||
|
||||
@@ -170,7 +197,8 @@ Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through
|
||||
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
|
||||
| Normal | `join_lines`, `undo`, `redo`, `command` |
|
||||
| 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 | `change_to_line_end`, `delete_to_line_end`, `copy_to_line_end`, `substitute_character`, `delete_word_forward`, `delete_word_backward`, `change_word_forward`, `change_word_backward` |
|
||||
| Normal, Visual | `replace_character` |
|
||||
| Normal | `save`, `force_save`, `quit`, `force_quit`, `restart`, `force_restart`, `save_and_quit`, `force_save_and_quit`, `plugins` |
|
||||
| Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` |
|
||||
| Normal | `follow_link` |
|
||||
@@ -252,7 +280,7 @@ config.bind "ctrl+f", config.core.right, mode: config.modes.tree
|
||||
config.bind "ctrl+h", nil, mode: config.modes.tree
|
||||
```
|
||||
|
||||
The diff's changed-file explorer and floating picker use these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's comparison; Working checkout After reuses its editable buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan.
|
||||
The diff's changed-file explorer and floating picker use these same tree and selection contexts, including remaps and disabled keys. Enter opens the selected file's comparison; writable After reuses its live buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan.
|
||||
|
||||
Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Escape closes the revision selector in either stage, but an opened comparison still closes explicitly through `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and exposes immutable `close`, `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.
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ Clipboard checks use isolated providers rather than reading or overwriting the d
|
||||
|
||||
Diff checks cover staged category/filter navigation, stale request cancellation, row alignment, read-only buffer isolation, and rendered panes. Repository integration fixtures exercise Git and Git-backed jj, advertised but unfetched remote branches/bookmarks/tags, imported historical commit/change IDs, conflicts, unusual paths, binary/mode/link/submodule changes, and unchanged active metadata. Remote fixtures are local bare copies; they never push. Git fixture identities and commit dates are fixed.
|
||||
|
||||
Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart.
|
||||
|
||||
Normal-picker checks cover staged/disk/unsaved status, binary and mode changes, exact and ambiguous renames, deleted paths, unborn repositories, aliases and unusual filenames, ignores and safe roots, linked worktrees, and read-only colocated/non-colocated/conflicted jj parents. Catalog/render checks retain unchanged rows, query/selection/expansion and Tree/List navigation, clear stale metadata, restore recreated files, and sanitize rename labels. Live PTY smoke checks A/M/D/R colors in floating and docked pickers with the line gutter disabled, Git HEAD/jj parent refresh, and unchanged jj operation state during polling.
|
||||
|
||||
Diff LSP integration checks reuse the real framed-protocol subprocess with test-controlled response/publication gates and local Git comparisons. Cover both immutable historical sides and writable Working After, snapshot text/URI isolation, Unicode UTF-16 requests/targets/diagnostics, normal overlong-column clamping, hover, completion/undo, and read-only editing guards. Definition routing must distinguish ranges overlapping actual changed rows from wholly unchanged/context/omitted targets, use full location-link target ranges with exact selection reveal and exclusive endpoints, route canonical aliases and old/new rename paths, load other changed files on demand, handle added/deleted files, preserve original tabs/panes/excerpts and shared dirty targets, retain snapshot identity in ordinary tabs, and fail atomically for missing/non-UTF-8 files, invalid ranges, and unsupported URIs. Delayed/superseded replies must remain stale across caret moves, edit/undo, side/file/pane/tab changes, and comparison closure/replacement; publications for disposed snapshot identities must not reach their replacements. Private editor checks gate pending repository-load cancellation and exact-row reveal without timing assumptions. Before marking the TODO complete, run native integration/unit checks and an actual terminal smoke of focused diff LSP actions, changed-line routing, external new tabs, and immutable/editable side behavior.
|
||||
|
||||
Changed-file explorer checks cover reuse of the compact directory tree, removed/historical paths, file-to-directory transitions, remapped tree/selection bindings, filter/open/focus transitions, right-dock geometry, and wheel routing without edits to the underlying document.
|
||||
@@ -69,5 +73,6 @@ Diff styling checks cover independent snapshot language/shebang/injection detect
|
||||
- Annotation coordinate validation fast-paths ASCII grapheme lengths using byte lengths with CRLF pair accounting, retaining Unicode segmentation for non-ASCII text. Validation remains atomic and still checks every token; large Unicode buffers or diagnostic batches can require foreground work.
|
||||
- Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing.
|
||||
- Repository diff currently requires Unix subprocess-group cancellation and Git-backed jj (or Git alone). Each file is limited to 16 MiB; each subprocess output stream and Working/conflict snapshot to 64 MiB; each command to 60 seconds. Limit, transport, and non-commit-tag errors are explicit rather than silently truncated. Temporary comparison stores are removed when the diff closes.
|
||||
- Normal-picker status uses the gutter worker's five-second command and 16 MiB stream limits. Catalogs are bounded to 100,000 paths; dirty-buffer batches to 1,024 files/64 MiB; individual overlays, conflict files, and rename candidates to 16 MiB; conflict/rename data to 64 MiB. Temporary baseline stores are retained for warm metadata polling and removed when the worker stops.
|
||||
|
||||
See [syntax caches](architecture.md#syntax-caches) for the current full-tree highlighting ceiling.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Diff hatching: two-column diagonal feasibility
|
||||
|
||||
## Finding
|
||||
|
||||
**Yes: Unicode has the exact two line segments needed.** Repeat the following pair, in this order, with each character occupying one terminal column:
|
||||
|
||||
| Position | Character | Code point | Official Unicode name |
|
||||
| --- | --- | --- | --- |
|
||||
| Left cell | | U+1FBD0 | BOX DRAWINGS LIGHT DIAGONAL MIDDLE RIGHT TO LOWER LEFT |
|
||||
| Right cell | | U+1FBD1 | BOX DRAWINGS LIGHT DIAGONAL UPPER RIGHT TO MIDDLE LEFT |
|
||||
|
||||
These are listed under **Character cell diagonals** on page 6 of the [official Unicode 17.0 Symbols for Legacy Computing chart](https://www.unicode.org/Public/17.0.0/charts/PDF/U1FB00.pdf); the exact names are also in [UnicodeData.txt](https://www.unicode.org/Public/17.0.0/ucd/UnicodeData.txt). They were introduced in **Unicode 16.0** (`1FBCB..1FBEF` in [DerivedAge.txt](https://www.unicode.org/Public/17.0.0/ucd/DerivedAge.txt)). The names specify endpoints, not a drawing direction: together they join the pair's bottom-left corner to the shared edge's midpoint, then to the pair's top-right corner.
|
||||
|
||||
Sample (glyphs may be missing or differently rendered in the Markdown viewer):
|
||||
|
||||
```text
|
||||
|
||||
|
||||
```
|
||||
|
||||
Equivalent escaped sequence: `\u{1FBD0}\u{1FBD1}`. This is two separate characters/cells, not one double-width character.
|
||||
|
||||
## Geometry
|
||||
|
||||
For physical cell width `W` and height `H`, the ideal joined stroke has horizontal run `2W` and vertical rise `H`, so its angle above horizontal is:
|
||||
|
||||
```text
|
||||
θ = atan(H / (2W))
|
||||
```
|
||||
|
||||
This is **45° iff `H = 2W`**. The current full-cell `╱` has ideal angle `atan(H / W)`; at `H = 2W`, that is approximately 63.4°. Thus the pair is shallower, but the actual angle depends on the terminal's cell dimensions, including line spacing. These are geometric deductions from the chart's endpoints, not measured rendering results.
|
||||
|
||||
Do not substitute U+1FBA0–U+1FBAE: their centre/cell-edge endpoints describe different segments (see page 5 of the same chart). In particular, a side midpoint to top/bottom centre has run `W/2` and rise `H/2`, preserving the full-cell diagonal's slope rather than halving it.
|
||||
|
||||
## Line segments, not filled mosaics
|
||||
|
||||
U+1FBD0/U+1FBD1 are **light box-drawing strokes**. The chart separately labels U+1FB3C–U+1FB6F as **Smooth mosaic terminal graphic characters**: their `BLOCK DIAGONAL` names describe filled regions bounded by diagonals, not thin lines. For example, U+1FB48 is LOWER RIGHT BLOCK DIAGONAL LOWER LEFT TO LOWER MIDDLE RIGHT and U+1FB42 is LOWER RIGHT BLOCK DIAGONAL UPPER MIDDLE LEFT TO UPPER RIGHT ([chart, pages 3–4](https://www.unicode.org/Public/17.0.0/charts/PDF/U1FB00.pdf), [UnicodeData.txt](https://www.unicode.org/Public/17.0.0/ucd/UnicodeData.txt)). Those are not equivalent replacements for light hatching.
|
||||
|
||||
## Support limits and current code
|
||||
|
||||
- Unicode 16.0 repertoire support is a prerequisite; the existence of these characters does not establish coverage in the selected terminal/font. Missing glyphs or fallback glyphs remain possible, and fallback geometry must be checked in the intended renderer. Unicode explicitly says its chart glyph shapes are **not prescriptive** and actual fonts may vary ([chart, page 1](https://www.unicode.org/Public/17.0.0/charts/PDF/U1FB00.pdf)). Pixel-perfect joins or 45° rendering cannot be promised.
|
||||
- The pair has East Asian Width **Neutral (`N`)**, from `1FB94..1FBEF` in [EastAsianWidth.txt](https://www.unicode.org/Public/17.0.0/ucd/EastAsianWidth.txt). This is not a terminal-width guarantee: [UAX #11, §2 and §4](https://www.unicode.org/reports/tr11/) explicitly requires terminal-specific tailoring and distinguishes font/layout width from the property. Both the application and terminal must agree on one column per glyph.
|
||||
- Rendering now alternates `` and ``, starting at each pane's left content edge, with `DarkGray` foreground and reset background in [src/tui.rs:3308–3316](../src/tui.rs#L3308-L3316). The phase stays fixed during scrolling; odd widths clip the final pair. Actual empty source lines remain unhatched. See [docs/editing.md:426](editing.md#L426).
|
||||
|
||||
## Terminal support
|
||||
|
||||
- **Ghostty 1.3.1:** The [released drawing source](https://github.com/ghostty-org/ghostty/blob/v1.3.1/src/font/sprite/draw/symbols_for_legacy_computing.zig#L1007-L1031) explicitly implements both segments with the required endpoints. Ghostty's [codepoint resolver](https://github.com/ghostty-org/ghostty/blob/main/src/font/CodepointResolver.zig#L120-L145) normally selects procedural sprite rendering before ordinary font lookup, so this does not require font coverage; explicit codepoint font overrides take precedence.
|
||||
- **Kitty:** [Current font dispatch](https://github.com/kovidgoyal/kitty/blob/master/kitty/fonts.c#L838-L886) routes this range to native box drawing, and [drawing source](https://github.com/kovidgoyal/kitty/blob/master/kitty/decorations.c#L2759-L2774) explicitly implements the two segments. This establishes support in inspected upstream source, not a minimum released version.
|
||||
- The local environment reported Herdr 0.9.3; installed binaries reported Ghostty 1.3.1 and Kitty 0.45.0. Herdr [runs inside an outer terminal](https://github.com/herdrdev/herdr), so its presence and these installations do not prove which renderer displays this session. Visual verification through the actual terminal/multiplexer remains necessary.
|
||||
|
||||
**Recommendation:** Use the two-character text tile if it looks correct in the target terminal. No image protocol or new dependency is needed. Older/other terminals may lack glyph coverage; exact 45° remains conditional on cell geometry.
|
||||
|
||||
## Runtime evidence
|
||||
|
||||
An executed disposable `rustc` smoke program used the repository's actual compiled `ratatui` and `unicode-width` dependencies, a fixed viewport, `Terminal<CrosstermBackend<Vec<u8>>>`, and the same per-cell `set_symbol`/style logic as the current renderer. Both U+1FBD0 and U+1FBD1 returned width `Some(1)`. At three rows high, widths 1, 2, 3, and 12 emitted exactly 3, 6, 9, and 36 glyphs respectively: no dropped or double-width cells. This establishes application-side width/emission behavior, **not** actual terminal glyph coverage, pixel joins, or the visible angle; those were not visually verified. The disposable program and binary were removed.
|
||||
|
||||
Implemented with no new dependency or graphics protocol. Verification: `cargo test diff_` (94 passing checks), `cargo build`, and `cargo fmt --package vamp --check`. A live PTY session opened `:diff` against a disposable jj baseline with two removed rows and emitted paired hatching at 100, 101, and 77 terminal columns; no U+2571 was emitted, the editor exited cleanly, and fixture content stayed unchanged. The fixture was removed. Terminal pixel joins remain visually unverified. The TODO item is complete.
|
||||
+35
-13
@@ -4,6 +4,12 @@
|
||||
|
||||
Default controls and editing behavior. For custom bindings, see [configuration](configuration.md); for language-server commands, see [native LSP](configuration.md#native-lsp).
|
||||
|
||||
Ordinary documents show repository changes beside their hybrid line numbers by default: green `▌` for additions, red for removals, and yellow for modifications. [Configure `git_gutter`](configuration.md) to use `+` / `-` / `~`, colored numbers, or no change indicators. Bar/symbol gutters keep the order **number, blank, marker, blank, text**; number-only modes use no extra column. Horizontal scrolling never moves indicators, and wrapped continuation rows do not repeat them.
|
||||
|
||||
The baseline is Git `HEAD` or Git-backed jj's parent `@-`, so indicators include unsaved edits and remain after saving until the repository baseline changes. A deletion marks the next surviving source row; at EOF it marks the last editor row, including the final empty row after a trailing newline. An empty file anchors removals on row 0. Replacements mark paired rows modified and extra current rows added. Historical snapshots do not show live changes. Eligible named files not yet on disk show nonempty unsaved text as added; tracked files missing on disk still compare their buffer text against the baseline. Non-repository/ignored/binary/symlink files, files over 16 MiB, and jj changes with multiple parents have no indicators.
|
||||
|
||||
Indicators refresh asynchronously. Typing and undo/redo retain the last completed indicators until the updated comparison arrives, avoiding flashes; indicator values and row positions can briefly lag edits.
|
||||
|
||||
## Default editing keys
|
||||
|
||||
| Mode | Keys |
|
||||
@@ -29,8 +35,10 @@ Default controls and editing behavior. For custom bindings, see [configuration](
|
||||
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
|
||||
| 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, Visual | `r<char>` replaces the current grapheme in Normal or every selected grapheme in Visual |
|
||||
| Normal | `s` deletes the current grapheme and enters Insert |
|
||||
| Normal | `d<count>w` / `d<count>b` delete forward/backward word spans, e.g. `d2w` / `d2b` |
|
||||
| Normal | `c<count>w` / `c<count>b` cut the same line-local spans as `d` and enter Insert, e.g. `c2w` / `c2b` |
|
||||
| Normal, Visual | `.` repeats the last successful native edit against the current targets; one undo group |
|
||||
| Normal | `/` 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 |
|
||||
@@ -97,7 +105,7 @@ The status bar displays a pending count. Esc cancels it without moving, collapsi
|
||||
|
||||
`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.
|
||||
The first `d`, `c`, or `y` waits for its second key and appears in the status bar. `d` accepts `d`, `w`, or `b`; `c` accepts `c`, `w`, or `b`; `y` accepts `y`. Counts before and after the operator multiply: `2d3w` deletes six forward word spans, `2c3b` cuts six backward spans and enters Insert, and `d2d` deletes two rows. Escape cancels only the pending prefix/count, preserving carets; an unmatched key cancels the prefix and then performs its ordinary action. Explicit bindings, disabled first keys, and a leader using that first key take precedence.
|
||||
|
||||
System clipboard publication must succeed before any deletion. Failure preserves text, carets, mode, undo/redo, and repeat intent. `dd` is one undo action; `cc` and subsequent replacement typing form one Insert undo group. Dot repeats the original requested row count at the current carets—even if the original action was clamped at EOF—and replays `cc` replacement text. Rebind the Normal-only `config.core.delete_line` / `config.core.change_line` actions to a single key or leader sequence without relying on the built-in doubled keys.
|
||||
|
||||
@@ -105,7 +113,9 @@ System clipboard publication must succeed before any deletion. Failure preserves
|
||||
|
||||
`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.
|
||||
`dw` / `db` cut characterwise spans from each caret to the forward/backward word-start boundary, using the same Unicode word boundaries as `w` / `b`, but confined to that caret's current logical line. Counts repeat the boundary motion, stopping at the line end/start without deleting its newline or touching neighboring lines; ordinary `w` / `b` navigation still crosses lines. Backward spans exclude the original caret character. Empty spans do not edit text. These are dedicated actions, not arbitrary operator–motion grammar. Suffix, substitution, and word cuts share the configured clipboard's failure-before-edit protection; all native edits support undo and dot repeat.
|
||||
|
||||
`cw` / `cb` cut exactly the same line-local spans as `dw` / `db`, including intervening whitespace, then enter Insert at each cut's start. Counts before and after `c` multiply (`2cw`, `c2w`, `2c3b`); excess counts stop at the current line's boundary. Empty spans still enter Insert, including on empty lines and for `cb` at column zero. The cut and subsequent replacement typing form one undo group; dot repeat recomputes the requested spans at the current carets and replays the replacement text. Rebind the Normal-only `config.core.change_word_forward` / `config.core.change_word_backward` actions for another route.
|
||||
|
||||
## Selections and multi-caret editing
|
||||
|
||||
@@ -117,7 +127,9 @@ Copy and cut return to Normal at each selection start; Visual cancellation keeps
|
||||
|
||||
Visual `c` removes all selected text into the configured clipboard and enters Insert at each selection start. In the default System policy, clipboard publication must succeed before any deletion; failures leave text and selections intact. Deletion and replacement typing form one undo action when Insert mode is exited, including through explicit command execution. Esc without typing commits the deletion alone. An identical replacement creates no history entry and preserves redo. Changing only characterwise empty final-row selections enters Insert without overwriting either clipboard; linewise empty final rows are genuine rows and publish a newline.
|
||||
|
||||
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.
|
||||
Visual `r<char>` replaces every selected grapheme with the literal character, preserving newline separators and the clipboard. It returns to Normal at each selection start as one undo action and supports `.` repeat. Esc while waiting for the character cancels the replacement without clearing selections. Identical replacements and newline-only selections do not add history or discard redo.
|
||||
|
||||
Bracketed paste broadcasts the same literal text to all Insert carets in a separate undo group, distinct from typing before/after it. A rejected paste leaves the pending change intact. Normal supports counted `yy`, `dd`, `cc`, `dw`, `db`, `cw`, and `cb`, but not arbitrary operator–motion grammar; blockwise Visual is also unsupported. `c`, `d`, and `y` type literal text in Insert mode.
|
||||
|
||||
Clipboard policy is session-wide and defaults to the system clipboard. Copy/cut store fragments in document order: one fragment broadcasts to every caret; matching fragment/caret counts pair in document order; any other count refuses paste without changing editing state. Empty clipboard paste does not edit text; copying/cutting only characterwise empty final-row selections does not overwrite either register. Linewise copy/cut retain whole-row shape, including a newline fragment for an empty final row. Open a new search or enter Ex commands after leaving Visual mode. Plugin commands execute once, not once per target: successful text effects collapse to the primary and apply once; rejected or message-only effects preserve the selection set. There is no selection-aware plugin API yet.
|
||||
|
||||
@@ -193,6 +205,10 @@ Live file buffers automatically check disk metadata every 250 ms, including buff
|
||||
|
||||
Paste is backend input: it is ignored in Normal, Command, Search, and Visual modes, and inserts literal text in Insert mode, with CRLF/CR normalization and a 4 MiB limit. Pasted text is one undo action, separate from typing before and after it. Rejected paste reports an error without changing text, cursor, mode, or pending insert history. Internal clipboard actions are separate from `Input::Paste` and use already-validated buffer text.
|
||||
|
||||
## Cursor-following scrolling
|
||||
|
||||
[Configure `scroll_distance`](configuration.md#preferences) to reserve space between the primary cursor or picker selection and the viewport edges. `0` (default) scrolls only to keep it visible; an integer reserves that many visual rows, and columns for unwrapped document text. `nil`, or a margin too large for the current viewport, centers it as far as the start of content permits. This policy applies to documents, historical/diff panes, file explorers, palettes, completion lists, and revision pickers. Reversing direction inside the margin does not move the viewport; resizing recalculates the usable margin.
|
||||
|
||||
## Mouse wheel scrolling
|
||||
|
||||
Mouse-wheel up/down scrolls the document view three display rows per event without moving the primary or secondary carets, changing selections, or editing text. The caret may scroll offscreen; its terminal cursor is hidden until visible again. Navigation (including a motion blocked at the document boundary), editing, or buffer navigation restores caret-following. Idle updates preserve a wheel-scrolled view, clamped to the document; resizing preserves unwrapped views and restores caret-following in ordinary wrapped buffers.
|
||||
@@ -278,7 +294,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` | Open the searchable buffer picker; stable IDs and paths, `%` active, `+` dirty |
|
||||
| `:buffers` | Open the searchable buffer picker; stable IDs and paths, `*` before the active buffer ID, `+` dirty |
|
||||
| `:buffer N` | Switch to buffer ID `N` |
|
||||
| `:buffer_next` / `:buffer_previous` | Switch in ascending-ID order, wrapping at either end |
|
||||
| `:buffer_delete [N]` | Close the active or named buffer; refuse unsaved changes |
|
||||
@@ -290,12 +306,16 @@ Switching from Insert commits its pending undo group and parks that view in Norm
|
||||
|
||||
**Space b l**, `:buffers`, or the **Buffers** command-palette action opens the picker. Type to filter IDs and paths with case-insensitive, whitespace-separated substring tokens; every token must match. Results retain ascending ID order. Ctrl+j/Ctrl+n/Down and Ctrl+k/Up select with wraparound, using the shared Selection bindings and overrides. Backspace edits the query; Ctrl+u clears it. Enter switches only the focused pane to the selected buffer without saving or discarding edits; no matches leave the picker open. Esc or Ctrl+p cancels without switching. Printable keys, including `:` and leader keys, belong to the query.
|
||||
|
||||
The picker uses the file explorer's centered floating-panel geometry, a cyan border, padded search input, and dimmed controls. Buffer IDs and status columns align across rows: IDs use the normal text foreground, a cyan `*` before the ID marks the active buffer, and a two-column gap separates the ID from the filename. Unsaved buffers show a yellow `+` in the first gap column; the selected row is blue. Paths under the home directory display as `~/…`, using the same component-aware abbreviation as the status bar; searching and file operations retain the original paths. Narrow or short terminals drop padding/borders as needed while keeping the query cursor and selected result visible whenever a result row fits.
|
||||
|
||||
The picker includes ordinary buffers from every tab and unnamed buffers as `[No Name]`; immutable diff snapshots are excluded. Labels sanitize path control characters, but selection uses stable buffer IDs, so displayed-name collisions still select the correct buffer. Switching out of a diff follows ordinary `:buffer N` behavior.
|
||||
|
||||
## File picker and explorer
|
||||
|
||||
Space o opens a floating **Tree** explorer directly in **INSERT/filter** mode, ready for typing; Space e opens or focuses the docked **Tree**, starting in **NORMAL/navigation** mode when new. Both use one catalog and instance. Invoking the other action applies that presentation's configured default while retaining query, selection identities, and surviving folder expansion. Floating Tree invocation enters filtering if not already filtering. These actions are available as `config.core.file_picker` and `config.core.file_explorer` in Normal/Visual and in the command palette.
|
||||
|
||||
Normal pickers and explorers show the same colored **A/M/D/R** markers as the diff file picker: green additions, yellow modifications, red deletions, and cyan renames (`old -> new`). Unchanged files remain visible. Status compares disk and unsaved buffer text with Git `HEAD` or Git-backed jj's single parent `@-`; it works in both Tree and List, even with `git_gutter = "none"`. Missing tracked files remain as deleted entries; opening one uses the usual missing-file behavior. Status refreshes on opening, edits/saves, focus regain, and once per second. Ctrl+r rescans the normal catalog and refreshes status; status polling alone does not rescan for newly created files.
|
||||
|
||||
While a docked Tree has focus in **NORMAL/navigation** mode, leader sequences work in ordinary and diff views: Space o replaces the dock with the floating picker, and Space e returns focus to the document like Escape while leaving the dock visible. Invoking Space e again from the document refocuses it. Tree/selection navigation overrides retain priority. In **INSERT/filter** mode and List query fields, printable leader keys remain literal query text rather than triggering editor actions; configured control-key action bindings remain available.
|
||||
|
||||
The floating explorer is centered, capped at 100 columns by 24 rows, and shrinks to fit smaller terminals. The tree mode appears in the title; the help row does not repeat it.
|
||||
@@ -355,23 +375,25 @@ Comparison discovery builds the changed-path/status catalog first. File text, no
|
||||
|
||||
**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.
|
||||
**Pinned After editability tracks the checkout without moving either endpoint**, checked asynchronously once per second, including inactive tabs and when `config.diff_follow_working_copy = false`. Git matches the selected commit to HEAD; jj matches its change ID so snapshot/description rewrites of the same change remain editable. A matching After uses the shared live buffer. Switching away restores the original read-only After snapshot; switching back reuses the live buffer's unsaved edits and undo history. A failed checkout check revokes pinned After editing until a successful check restores eligibility.
|
||||
|
||||
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.
|
||||
Refresh retains the selected path when still present, explorer query/view/visibility, logical text-side focus, and split/unified/orientation/wrap choices; moving the Working endpoint clears review marks, while pinned checkout-only transitions retain them. Removed selections fall back to the first changed file. Failed refreshes keep the previous comparison visible. Superseded file loads and LSP navigation cannot apply to the new comparison; snapshots still displayed in other panes or tabs remain read-only.
|
||||
|
||||
The changed-file explorer is **docked on the right**. In the default split layout, modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain both source sides. Before and unmatched pinned After snapshots are read-only. **Working checkout** or a checkout-matched pinned After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. Editable After supports native edits, completion, undo/redo, and `:w`. Removed files, rename-only files, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits.
|
||||
|
||||
Comparisons start in **split, vertical (left/right)** view by default. In Normal mode, **Space v** cycles split/unified; **Space s** switches a split between vertical (left/right) and horizontal (above/below, Before above After). Canonical commands are `:diff.view.toggle` and `:diff.orientation.toggle`; both are available in the palette and as remappable `config.core.diff.view.toggle` / `config.core.diff.orientation.toggle` actions. Configure new-comparison defaults with `config.diff_view_mode = "split"` or `"unified"` and `config.diff_split_orientation = "vertical"` or `"horizontal"`. Current choices survive file and tab switches; closing and reopening uses the configured defaults. Orientation stays unchanged while unified is active.
|
||||
|
||||
**Unified** uses one full-width text pane with both original line-number columns: context appears once, then each changed run shows removed (`-`) lines followed by added (`+`) lines, without alignment holes. Tab/Shift+Tab still switches logical Before/After focus, preserving native source coordinates, read-only protection, and Working After editing. The border shows the combined revisions and the focused side's editability. Word wrap, syntax/semantic and character highlighting, search, selections, excerpt expansion, and wheel scrolling work in either view.
|
||||
**Unified** uses one full-width text pane with both original line-number columns: context appears once, then each changed run shows removed (`-`) lines followed by added (`+`) lines, without alignment holes. Tab/Shift+Tab still switches logical Before/After focus, preserving native source coordinates, read-only protection, and writable After editing. The border shows the combined revisions and the focused side's editability. Word wrap, syntax/semantic and character highlighting, search, selections, excerpt expansion, and wheel scrolling work in either view.
|
||||
|
||||
Diff metadata lives in the text-pane borders instead of a separate info row: split panes show their own Before/After revision labels at the bottom-left; a single visible pane shows the combined `before → after` label there. Each pane shows its read-only/editable state at the top-right, independently of keyboard focus. Before is always read-only; After is editable only for an eligible Working-checkout file. The changed-file explorer and panes start directly below configured chrome, and there is no native-buffer help footer. Loading/error/file notices remain visible when applicable. Narrow borders prioritize state and clip titles without painting over the gutter or source text.
|
||||
Diff metadata lives in the text-pane borders instead of a separate info row: split panes show their own Before/After revision labels at the bottom-left; a single visible pane shows the combined `before → after` label there. Each pane shows its read-only/editable state at the top-right, independently of keyboard focus. Before is always read-only; After is editable only for an eligible Working-checkout or checkout-matched pinned file. The changed-file explorer and panes start directly below configured chrome, and there is no native-buffer help footer. Loading/error/file notices remain visible when applicable. Narrow borders prioritize state and clip titles without painting over the gutter or source text.
|
||||
|
||||
For jj-managed repositories, revision entries and border labels display only the change ID; Git repositories keep commit-ID labels, and named bookmarks retain their names. The backing commit ID remains the exact comparison target and can still be used to filter revisions.
|
||||
|
||||
Text panes use normal buffer interaction regardless of writability: motions and counts, search, Visual/Visual Line selections, multiple carets, yank, remapped bindings, and non-mutating actions work in Before and After. Read-only snapshots reject insert/delete/change/paste, repeat edits, undo/redo, mutating plugin effects, and saves, including saves to another path. Their text retains the revision's final newline. Snapshots use separate virtual LSP document identities and do not appear in ordinary buffer lists or dirty-buffer comparisons. Closing the diff disposes unreferenced snapshots and restores the live or original buffer in its workspace pane; snapshots displayed by other panes or tabs remain read-only until closed. Explicit buffer commands and Markdown/plugin navigation retain their ordinary behavior; native LSP definitions use the routing below.
|
||||
|
||||
**LSP definition, hover, and diagnostics work normally in focused Before and After text panes**, including historical snapshots. Each snapshot is served with its immutable revision text and its own document URI, not the current disk contents. Completion and completion edits are available only in writable Working-checkout After; they use the shared buffer's normal undo history. Revision selectors and the changed-file explorer are not document-action targets.
|
||||
**LSP definition, hover, and diagnostics work normally in focused Before and After text panes**, including historical snapshots. Each snapshot is served with its immutable revision text and its own document URI, not the current disk contents. Completion and completion edits are available only in writable After; they use the shared buffer's normal undo history. Revision selectors and the changed-file explorer are not document-action targets.
|
||||
|
||||
Accepted LSP definitions center the selection's caret row once in the destination pane, including wrapped visual rows and revealed diff excerpts. Centering uses the focused pane's actual height in either split orientation or unified view. The next motion does not recenter; failed or superseded definitions preserve the current logical viewport.
|
||||
Accepted LSP definitions center the selection's caret row once in the destination pane, including wrapped visual rows and revealed diff excerpts. Centering uses the focused pane's actual height in either split orientation or unified view. Subsequent motions resume the configured `scroll_distance` policy; failed or superseded definitions preserve the current logical viewport.
|
||||
|
||||
A definition whose source range **overlaps an actual added, removed, or replaced line** stays in the same comparison and selects the correct file and side, loading file details on demand. For a location link, the symbol's full target range determines overlap and its selection range determines the caret: an unchanged declaration with a changed body still belongs in the comparison. Source coordinates belong to that side, including old paths for renames; navigation reveals the exact selection row, even when previously omitted, never the nearest excerpt. A wholly unchanged target opens a **new ordinary tab**, even when it is visible context or omitted text in a changed file. Files outside the comparison's changes also open in a new tab. The original comparison, pane layout, source caret, excerpts, and dirty buffers remain intact. Loaded targets share their existing text and undo history rather than reloading disk; references to snapshot URIs retain the historical text and read-only identity in the new tab, even after the comparison closes. Unreadable files, invalid UTF-8, invalid source ranges, and unsupported remote URIs do not create a tab or disturb the source.
|
||||
|
||||
@@ -381,7 +403,7 @@ File rows show colored **A** (added, green), **M** (modified, yellow), **D** (re
|
||||
|
||||
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.
|
||||
Review marks belong only to the open comparison and survive filtering, file navigation, lazy loading, tabs, split/unified/orientation changes, and pinned checkout-only transitions. A new or recomputed comparison clears them. Editing a writable After file clears only that file's mark; unchanged refreshes and saves preserve it. Marks do not change document text, undo history, or repository state, and are not persisted after closing the comparison.
|
||||
|
||||
Text panes show change excerpts with **10 unchanged lines above and below** each change, preserving original file line numbers. Overlapping or touching excerpts merge; omitted stretches show an unchanged-line count in both panes. Files with only metadata changes show their notice or “No text changes” rather than unrelated source.
|
||||
|
||||
@@ -401,7 +423,7 @@ Character-wise highlighting is enabled automatically within paired changed lines
|
||||
|
||||
Diff backgrounds blend the terminal's default background with its red/green palette colors: an 18% accent for changed rows and a stronger 38% accent for changed characters, both computed from the original background. Read-only OSC 11/4 queries run after the initial paint, on focus regain, and whenever a subscribed DEC 2031 color-scheme notification arrives. While a diff is visible in any pane, a nonblocking one-second palette probe also catches external theme changes without a notification, including changes between two themes with the same light/dark preference. Ordinary editor views do not poll; cached colors apply across editor panes. If the complete palette is unavailable within 250 ms, change backgrounds remain normal and gutter colors/markers still identify changes.
|
||||
|
||||
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.
|
||||
Missing counterparts use repeated muted `` diagonals (U+1FBD0/U+1FBD1), one glyph per column, on the normal terminal background with the same terminal `DarkGray` foreground as ordinary gutter text. Each pair joins the bottom-left corner to the top-right corner across two columns, giving a 45° angle when cells are twice as tall as they are wide. The pattern starts at each pane's left content edge, stays fixed during scrolling, and clips the final pair at odd widths. These Unicode 16 characters need a supporting terminal or font; Ghostty 1.3.1 draws them natively. Pixel-perfect joins and the exact angle depend on terminal cell geometry and rendering. Actual empty source lines retain their normal/change background and are not hatched.
|
||||
|
||||
In Normal mode, Tab/Shift+Tab cycles focus between Files and the visible text panes, skipping absent panes. Switching Before/After synchronizes the primary cursor by aligned diff row and display column. A missing counterpart uses the nearest visible source row; shorter lines clamp the column while retaining the preferred display column for the return trip. Vertical wheel input scrolls the visible panes without moving the focused text caret; switching sides by vertical wheel also synchronizes the cursor. Over Files it navigates visible tree rows without opening them. Horizontal wheel input in either split synchronizes the Before/After horizontal offsets, bounded by the longer side, without changing focus, cursor, or selections; over Files it is consumed. Added/deleted files retain single-pane scrolling. While a comparison is open, Escape cancels modes, prompts, or filtering, or returns picker focus to the last bound text pane; it leaves the comparison open. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias.
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ 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.scroll_distance = 0 # nonnegative edge distance, or nil to center
|
||||
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
|
||||
config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\n" => "↵" }
|
||||
config.whitespace_scope = "all" # or "leading" for indentation only; newline markers remain independent
|
||||
config.file_explorer_side = "right"
|
||||
config.file_explorer_view = "tree"
|
||||
config.file_picker_view = "tree"
|
||||
|
||||
+22
-8
@@ -154,11 +154,20 @@ impl Drop for Temporary {
|
||||
}
|
||||
|
||||
// Same process-group cancellation as repository comparisons, with a smaller output/time budget.
|
||||
fn run(
|
||||
mut command: Command,
|
||||
pub(crate) fn run(
|
||||
command: Command,
|
||||
input: &[u8],
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let (code, bytes) = run_status(command, input, cancelled)?;
|
||||
Ok((code == Some(0)).then_some(bytes))
|
||||
}
|
||||
|
||||
pub(crate) fn run_status(
|
||||
mut command: Command,
|
||||
input: &[u8],
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
) -> Result<(Option<i32>, Vec<u8>), String> {
|
||||
if cancelled() {
|
||||
return Err("Blame cancelled".into());
|
||||
}
|
||||
@@ -176,6 +185,7 @@ fn run(
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_NO_REPLACE_OBJECTS", "1")
|
||||
.env("GIT_NO_LAZY_FETCH", "1")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
@@ -239,20 +249,24 @@ fn run(
|
||||
return Err(error.into());
|
||||
}
|
||||
let bytes = bytes.map_err(str::to_owned)?;
|
||||
if !status.unwrap().success() {
|
||||
return Ok(None);
|
||||
let status = status.unwrap();
|
||||
if status.success() {
|
||||
written.map_err(|_| "Cannot supply blame contents")?;
|
||||
}
|
||||
written.map_err(|_| "Cannot supply blame contents")?;
|
||||
Ok(Some(bytes))
|
||||
Ok((status.code(), bytes))
|
||||
})
|
||||
}
|
||||
}
|
||||
fn git(root: &Path) -> Command {
|
||||
pub(crate) fn git(root: &Path) -> Command {
|
||||
let mut command = git_without_literal_pathspecs(root);
|
||||
command.arg("--literal-pathspecs");
|
||||
command
|
||||
}
|
||||
pub(crate) fn git_without_literal_pathspecs(root: &Path) -> Command {
|
||||
let mut command = Command::new("git");
|
||||
command
|
||||
.args([
|
||||
"--no-pager",
|
||||
"--literal-pathspecs",
|
||||
"-c",
|
||||
"core.hooksPath=/dev/null",
|
||||
"-c",
|
||||
|
||||
+166
-44
@@ -1,7 +1,7 @@
|
||||
use crate::editor::{BackspaceKind, Input};
|
||||
use crate::files::{FilePicker, FilePresentation, FileView};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
@@ -108,7 +108,7 @@ pub struct Comparison {
|
||||
pub after: String,
|
||||
pub files: Vec<ChangedFile>,
|
||||
pub root: PathBuf,
|
||||
/// Regular working-After candidates; loaded binary details are excluded.
|
||||
/// Eligible regular-file After paths; loaded binary details are excluded.
|
||||
pub editable_paths: BTreeSet<PathBuf>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
@@ -122,8 +122,9 @@ pub enum Operation {
|
||||
mode: ComparisonMode,
|
||||
buffers: Box<[(PathBuf, String)]>,
|
||||
},
|
||||
RefreshWorking {
|
||||
RefreshCheckout {
|
||||
comparison_id: u64,
|
||||
follow_working: bool,
|
||||
buffers: Box<[(PathBuf, String)]>,
|
||||
},
|
||||
LoadFile {
|
||||
@@ -135,7 +136,7 @@ pub enum Operation {
|
||||
pub enum Outcome {
|
||||
Catalog(Catalog),
|
||||
Comparison(Comparison),
|
||||
WorkingRefresh(Option<Comparison>),
|
||||
CheckoutRefresh(Option<Comparison>),
|
||||
File {
|
||||
comparison_id: u64,
|
||||
file_index: usize,
|
||||
@@ -195,7 +196,7 @@ pub enum DisplayRow {
|
||||
enum RequestKind {
|
||||
Catalog,
|
||||
Comparison,
|
||||
WorkingRefresh {
|
||||
CheckoutRefresh {
|
||||
comparison_id: u64,
|
||||
},
|
||||
File {
|
||||
@@ -238,6 +239,7 @@ pub struct DiffState {
|
||||
pub live_revision: Option<u64>,
|
||||
pub follow_caret: bool,
|
||||
live_files: BTreeSet<usize>,
|
||||
frozen_after: BTreeMap<usize, (Vec<String>, bool)>,
|
||||
context_lines: usize,
|
||||
expand_lines: usize,
|
||||
pending: Option<Operation>,
|
||||
@@ -285,6 +287,7 @@ impl DiffState {
|
||||
live_revision: None,
|
||||
follow_caret: false,
|
||||
live_files: BTreeSet::new(),
|
||||
frozen_after: BTreeMap::new(),
|
||||
context_lines: 10,
|
||||
expand_lines: 15,
|
||||
pending: Some(Operation::Discover),
|
||||
@@ -317,31 +320,32 @@ impl DiffState {
|
||||
|
||||
pub(crate) fn request_ready(&self) -> bool {
|
||||
self.pending.as_ref().is_some_and(|operation| {
|
||||
!self.refresh_pending() || matches!(operation, Operation::RefreshWorking { .. })
|
||||
!self.refresh_pending() || matches!(operation, Operation::RefreshCheckout { .. })
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_pending(&self) -> bool {
|
||||
matches!(self.request_kind, Some(RequestKind::WorkingRefresh { .. }))
|
||||
matches!(self.request_kind, Some(RequestKind::CheckoutRefresh { .. }))
|
||||
}
|
||||
|
||||
pub(crate) fn queue_working_refresh(&mut self) {
|
||||
pub(crate) fn queue_checkout_refresh(&mut self, follow_working: bool) {
|
||||
if self.phase != Phase::View
|
||||
|| self.loading
|
||||
|| self.request_kind.is_some()
|
||||
|| self.pending.is_some()
|
||||
|| self.comparison.is_none()
|
||||
|| !self
|
||||
|| self
|
||||
.after
|
||||
.as_ref()
|
||||
.is_some_and(|after| after.target == RevisionTarget::Working)
|
||||
.is_none_or(|after| after.target == RevisionTarget::Working && !follow_working)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(comparison_id) = self.comparison_id {
|
||||
self.request_kind = Some(RequestKind::WorkingRefresh { comparison_id });
|
||||
self.pending = Some(Operation::RefreshWorking {
|
||||
self.request_kind = Some(RequestKind::CheckoutRefresh { comparison_id });
|
||||
self.pending = Some(Operation::RefreshCheckout {
|
||||
comparison_id,
|
||||
follow_working,
|
||||
buffers: Box::new([]),
|
||||
});
|
||||
}
|
||||
@@ -349,7 +353,7 @@ impl DiffState {
|
||||
|
||||
fn queue(&mut self, operation: Operation) {
|
||||
if self.refresh_pending()
|
||||
&& !matches!(self.pending, Some(Operation::RefreshWorking { .. }))
|
||||
&& !matches!(self.pending, Some(Operation::RefreshCheckout { .. }))
|
||||
&& matches!(operation, Operation::LoadFile { .. })
|
||||
{
|
||||
// Finish the refresh before issuing a load against its frozen comparison ID.
|
||||
@@ -378,20 +382,28 @@ impl DiffState {
|
||||
if self.request_kind.is_none() {
|
||||
return false;
|
||||
}
|
||||
if let Some(RequestKind::WorkingRefresh { comparison_id }) = self.request_kind {
|
||||
if let Some(RequestKind::CheckoutRefresh { 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)) => {
|
||||
Ok(Outcome::CheckoutRefresh(None)) => {
|
||||
if let Some(operation) = deferred {
|
||||
self.queue(operation);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Ok(Outcome::WorkingRefresh(Some(comparison))) => {
|
||||
Ok(Outcome::CheckoutRefresh(Some(mut comparison))) => {
|
||||
let pinned = self
|
||||
.after
|
||||
.as_ref()
|
||||
.is_some_and(|after| after.target != RevisionTarget::Working);
|
||||
let view = (self.top, self.left_before, self.left_after);
|
||||
if !pinned {
|
||||
self.reviewed_files.clear();
|
||||
}
|
||||
let selected = self
|
||||
.comparison
|
||||
.as_ref()
|
||||
@@ -402,6 +414,19 @@ impl DiffState {
|
||||
comparison.files.iter().position(|file| &file.path == path)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if pinned {
|
||||
self.restore_frozen_after();
|
||||
if let Some(old) = self.comparison.take() {
|
||||
// Checkout-only refreshes retain the frozen catalog order.
|
||||
for (file, replacement) in
|
||||
old.files.into_iter().zip(&mut comparison.files)
|
||||
{
|
||||
if file.loaded {
|
||||
*replacement = file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(files) = &mut self.files {
|
||||
files.replace_paths(
|
||||
comparison
|
||||
@@ -414,25 +439,34 @@ impl DiffState {
|
||||
self.comparison = Some(comparison);
|
||||
self.comparison_id = Some(id);
|
||||
self.snapshot_roots = [None, None];
|
||||
self.reviewed_files.clear();
|
||||
self.frozen_after.clear();
|
||||
self.live_files.clear();
|
||||
self.live_buffer = None;
|
||||
self.live_revision = None;
|
||||
self.follow_caret = false;
|
||||
self.prepare_selected_file();
|
||||
if pinned {
|
||||
self.top = view.0.min(self.display_rows.len().saturating_sub(1));
|
||||
self.left_before = view.1.min(self.max_before);
|
||||
self.left_after = view.2.min(self.max_after);
|
||||
}
|
||||
self.normalize_focus();
|
||||
}
|
||||
Err(error) => {
|
||||
self.revoke_pinned_after();
|
||||
if let Some(operation) = deferred {
|
||||
self.queue(operation);
|
||||
}
|
||||
self.error = Some(error);
|
||||
}
|
||||
Ok(_) => self.error = Some("Unexpected repository response".into()),
|
||||
Ok(_) => {
|
||||
self.revoke_pinned_after();
|
||||
self.error = Some("Unexpected repository response".into());
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if matches!(result, Ok(Outcome::WorkingRefresh(_))) {
|
||||
if matches!(result, Ok(Outcome::CheckoutRefresh(_))) {
|
||||
return false;
|
||||
}
|
||||
if let Ok(Outcome::File {
|
||||
@@ -483,6 +517,7 @@ impl DiffState {
|
||||
}
|
||||
Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => {
|
||||
self.live_files.clear();
|
||||
self.frozen_after.clear();
|
||||
self.reviewed_files.clear();
|
||||
self.live_buffer = None;
|
||||
self.live_revision = None;
|
||||
@@ -972,18 +1007,48 @@ impl DiffState {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn after_editable(&self) -> bool {
|
||||
matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After)
|
||||
&& self.working_after_available()
|
||||
fn revoke_pinned_after(&mut self) {
|
||||
if self
|
||||
.after
|
||||
.as_ref()
|
||||
.is_none_or(|after| after.target == RevisionTarget::Working)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(comparison) = &mut self.comparison {
|
||||
comparison.editable_paths.clear();
|
||||
}
|
||||
self.restore_frozen_after();
|
||||
self.live_files.clear();
|
||||
self.live_buffer = None;
|
||||
self.live_revision = None;
|
||||
self.follow_caret = false;
|
||||
self.rebuild_file(false);
|
||||
self.refresh_files();
|
||||
}
|
||||
|
||||
pub(crate) fn working_after_available(&self) -> bool {
|
||||
fn restore_frozen_after(&mut self) {
|
||||
if let Some(comparison) = &mut self.comparison {
|
||||
for (index, (after, eof)) in std::mem::take(&mut self.frozen_after) {
|
||||
if let Some(file) = comparison.files.get_mut(index) {
|
||||
file.after = after;
|
||||
file.after_eof = eof;
|
||||
file.hunks =
|
||||
line_hunks(&file.before, file.before_eof, &file.after, file.after_eof);
|
||||
update_eof_notice(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn after_editable(&self) -> bool {
|
||||
matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After)
|
||||
&& self.live_after_available()
|
||||
}
|
||||
|
||||
pub(crate) fn live_after_available(&self) -> bool {
|
||||
self.phase == Phase::View
|
||||
&& self.selected_file_loaded()
|
||||
&& self
|
||||
.after
|
||||
.as_ref()
|
||||
.is_some_and(|revision| revision.target == RevisionTarget::Working)
|
||||
&& self.comparison.as_ref().is_some_and(|comparison| {
|
||||
comparison.files.get(self.file).is_some_and(|file| {
|
||||
comparison.editable_paths.contains(&file.path)
|
||||
@@ -1075,6 +1140,15 @@ impl DiffState {
|
||||
.is_some_and(|file| file.loaded)
|
||||
}
|
||||
|
||||
/// Source rows without the editor's virtual terminal row, preserving real empty lines.
|
||||
pub(crate) fn after_source_lines<'a>(&self, file: &'a ChangedFile) -> &'a [String] {
|
||||
if self.live_files.contains(&self.file) {
|
||||
live_content(&file.after, file.after_eof)
|
||||
} else {
|
||||
&file.after
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_selected_file(&mut self) {
|
||||
if self.refresh_pending() {
|
||||
self.align_file();
|
||||
@@ -1302,13 +1376,22 @@ impl DiffState {
|
||||
}
|
||||
|
||||
pub fn refresh_after(&mut self, lines: &[String], revision: u64) {
|
||||
if !self.working_after_available() {
|
||||
if !self.live_after_available() {
|
||||
return;
|
||||
}
|
||||
if self.live_revision == Some(revision) {
|
||||
return;
|
||||
}
|
||||
let file = &self.comparison.as_ref().unwrap().files[self.file];
|
||||
if self
|
||||
.after
|
||||
.as_ref()
|
||||
.is_some_and(|after| after.target != RevisionTarget::Working)
|
||||
{
|
||||
self.frozen_after
|
||||
.entry(self.file)
|
||||
.or_insert_with(|| (file.after.clone(), file.after_eof));
|
||||
}
|
||||
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) {
|
||||
@@ -3297,19 +3380,19 @@ mod tests {
|
||||
files.start_search();
|
||||
files.query = "file".into();
|
||||
files.refilter();
|
||||
let original = format!("{state:?}");
|
||||
state.queue_working_refresh();
|
||||
state.queue_checkout_refresh(true);
|
||||
assert!(matches!(
|
||||
state.take_request(),
|
||||
Some(Operation::RefreshWorking {
|
||||
Some(Operation::RefreshCheckout {
|
||||
comparison_id: 7,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(!state.apply_result(8, Ok(Outcome::WorkingRefresh(None))));
|
||||
assert_eq!(format!("{state:?}"), original);
|
||||
assert!(!state.apply_result(8, Ok(Outcome::CheckoutRefresh(None))));
|
||||
assert_eq!(state.comparison_id, Some(7));
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
|
||||
state.queue_working_refresh();
|
||||
state.queue_checkout_refresh(true);
|
||||
state.take_request();
|
||||
assert!(state.apply_result(9, Err("refresh failed".into())));
|
||||
assert_eq!(state.comparison_id, Some(7));
|
||||
@@ -3317,14 +3400,14 @@ mod tests {
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
||||
assert_eq!(state.error.as_deref(), Some("refresh failed"));
|
||||
|
||||
state.queue_working_refresh();
|
||||
state.queue_checkout_refresh(true);
|
||||
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!(state.apply_result(10, Ok(Outcome::CheckoutRefresh(Some(replacement)))));
|
||||
assert_eq!(state.comparison_id, Some(10));
|
||||
assert_eq!(state.file, 1);
|
||||
assert_eq!(state.focus, Focus::After);
|
||||
@@ -3341,17 +3424,55 @@ mod tests {
|
||||
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);
|
||||
assert!(!state.apply_result(7, Ok(Outcome::CheckoutRefresh(None))));
|
||||
assert_eq!(state.comparison_id, Some(10));
|
||||
assert_eq!(state.file, 1);
|
||||
state.after.as_mut().unwrap().target = RevisionTarget::Commit("historical".into());
|
||||
state.queue_working_refresh();
|
||||
assert!(!state.request_queued());
|
||||
state.queue_checkout_refresh(false);
|
||||
assert!(state.request_queued());
|
||||
assert!(matches!(
|
||||
state.take_request(),
|
||||
Some(Operation::RefreshCheckout {
|
||||
follow_working: false,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(!state.apply_result(11, Ok(Outcome::CheckoutRefresh(None))));
|
||||
state.reviewed_files.insert(1);
|
||||
state.queue_checkout_refresh(false);
|
||||
state.take_request();
|
||||
let mut demoted = live_state(vec!["old".into()], vec!["ahead".into()], false)
|
||||
.comparison
|
||||
.take()
|
||||
.unwrap();
|
||||
demoted.files.insert(0, comparison().files.remove(0));
|
||||
demoted.editable_paths.clear();
|
||||
assert!(state.apply_result(12, Ok(Outcome::CheckoutRefresh(Some(demoted)))));
|
||||
assert_eq!(state.reviewed_files, BTreeSet::from([1]));
|
||||
assert!(!state.live_after_available());
|
||||
state
|
||||
.comparison
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.editable_paths
|
||||
.insert("file".into());
|
||||
state.refresh_after(&["dirty".into()], 1);
|
||||
assert_eq!(state.comparison.as_ref().unwrap().files[1].after, ["dirty"]);
|
||||
state.queue_checkout_refresh(false);
|
||||
state.take_request();
|
||||
assert!(state.apply_result(13, Err("checkout unavailable".into())));
|
||||
assert!(!state.live_after_available());
|
||||
assert_eq!(state.comparison_id, Some(12));
|
||||
assert_eq!(state.comparison.as_ref().unwrap().files[1].after, ["ahead"]);
|
||||
state.after.as_mut().unwrap().target = RevisionTarget::Working;
|
||||
state.queue_checkout_refresh(false);
|
||||
assert!(!state.request_queued());
|
||||
state.load_file(0);
|
||||
let user_request = format!("{state:?}");
|
||||
state.queue_working_refresh();
|
||||
assert_eq!(format!("{state:?}"), user_request);
|
||||
state.queue_checkout_refresh(true);
|
||||
assert!(matches!(
|
||||
state.pending,
|
||||
Some(Operation::LoadFile { file_index: 0, .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3807,6 +3928,7 @@ mod tests {
|
||||
assert_eq!(empty.rows[0].kind, RowKind::Equal);
|
||||
assert_eq!(empty.after_display_row(0), Some(0));
|
||||
empty.after.as_mut().unwrap().target = RevisionTarget::Commit("historic".into());
|
||||
empty.comparison.as_mut().unwrap().editable_paths.clear();
|
||||
empty.refresh_after(&["must not apply".into()], 2);
|
||||
assert_eq!(empty.comparison.as_ref().unwrap().files[0].after, [""]);
|
||||
empty.handle(Input::Escape);
|
||||
|
||||
+494
-84
@@ -25,6 +25,14 @@ use unicode_width::UnicodeWidthStr;
|
||||
const MAX_EDIT_BYTES: usize = 4 * 1024 * 1024;
|
||||
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WhitespaceScope {
|
||||
#[default]
|
||||
All,
|
||||
Leading,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum Input {
|
||||
Char(char),
|
||||
@@ -189,6 +197,13 @@ pub struct PaletteEntry {
|
||||
pub bindings: Vec<String>,
|
||||
pub enabled: bool,
|
||||
pub modes: Vec<Mode>,
|
||||
pub buffer: Option<PaletteBuffer>,
|
||||
}
|
||||
|
||||
pub struct PaletteBuffer {
|
||||
pub id: usize,
|
||||
pub active: bool,
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
|
||||
@@ -310,6 +325,10 @@ enum EditStart {
|
||||
target: char,
|
||||
count: usize,
|
||||
},
|
||||
ReplaceSelection {
|
||||
target: char,
|
||||
selector: SelectionIntent,
|
||||
},
|
||||
Delete,
|
||||
Join,
|
||||
Paste {
|
||||
@@ -544,7 +563,7 @@ core_actions![
|
||||
(
|
||||
"core.replace_character",
|
||||
Action::ReplaceCharacter,
|
||||
&[Mode::Normal]
|
||||
&[Mode::Normal, Mode::Visual]
|
||||
),
|
||||
(
|
||||
"core.substitute_character",
|
||||
@@ -561,6 +580,16 @@ core_actions![
|
||||
Action::DeleteWordBackward,
|
||||
&[Mode::Normal]
|
||||
),
|
||||
(
|
||||
"core.change_word_forward",
|
||||
Action::ChangeWordForward,
|
||||
&[Mode::Normal]
|
||||
),
|
||||
(
|
||||
"core.change_word_backward",
|
||||
Action::ChangeWordBackward,
|
||||
&[Mode::Normal]
|
||||
),
|
||||
("core.paste_before", Action::PasteBefore, &[Mode::Normal]),
|
||||
("core.paste_after", Action::PasteAfter, &[Mode::Normal]),
|
||||
(
|
||||
@@ -946,6 +975,8 @@ impl Action {
|
||||
| Self::SubstituteCharacter
|
||||
| Self::DeleteWordForward
|
||||
| Self::DeleteWordBackward
|
||||
| Self::ChangeWordForward
|
||||
| Self::ChangeWordBackward
|
||||
| Self::Cut
|
||||
| Self::Change
|
||||
| Self::PasteBefore
|
||||
@@ -1138,7 +1169,6 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
|
||||
('C', ChangeToLineEnd),
|
||||
('D', DeleteToLineEnd),
|
||||
('Y', CopyToLineEnd),
|
||||
('r', ReplaceCharacter),
|
||||
('s', SubstituteCharacter),
|
||||
('u', Undo),
|
||||
('U', Redo),
|
||||
@@ -1191,6 +1221,7 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
|
||||
('v', Select),
|
||||
('V', SelectLinewise),
|
||||
('.', RepeatLastEdit),
|
||||
('r', ReplaceCharacter),
|
||||
] {
|
||||
bindings
|
||||
.entry(mode)
|
||||
@@ -1332,6 +1363,7 @@ pub struct DocumentInfo<'a> {
|
||||
pub path: Option<&'a Path>,
|
||||
pub lines: &'a [String],
|
||||
pub save_revision: u64,
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
struct AnnotationSet {
|
||||
@@ -1492,6 +1524,12 @@ impl FileStamp {
|
||||
}
|
||||
}
|
||||
|
||||
struct GutterChanges {
|
||||
revision: u64,
|
||||
path: PathBuf,
|
||||
lines: Vec<Option<crate::gutter::Change>>,
|
||||
}
|
||||
|
||||
struct Buffer {
|
||||
id: usize,
|
||||
diff_snapshot: Option<(u64, usize, Focus)>,
|
||||
@@ -1505,6 +1543,7 @@ struct Buffer {
|
||||
save_revision: u64,
|
||||
diagnostics: BTreeMap<String, AnnotationSet>,
|
||||
semantic_tokens: Option<Arc<[SemanticToken]>>,
|
||||
gutter_changes: Option<GutterChanges>,
|
||||
view: ViewState,
|
||||
path: Option<PathBuf>,
|
||||
identity: Option<PathBuf>,
|
||||
@@ -1561,6 +1600,7 @@ impl Buffer {
|
||||
save_revision: 0,
|
||||
diagnostics: BTreeMap::new(),
|
||||
semantic_tokens: None,
|
||||
gutter_changes: None,
|
||||
view: ViewState {
|
||||
targets: vec![Target::caret(Cursor::default())],
|
||||
..ViewState::empty()
|
||||
@@ -1647,6 +1687,7 @@ impl Buffer {
|
||||
.or(self.path.as_deref()),
|
||||
lines: &self.lines,
|
||||
save_revision: self.save_revision,
|
||||
dirty: self.dirty(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1733,9 +1774,14 @@ pub struct Editor {
|
||||
bottom_bar: ChromeLine,
|
||||
popup: PopupConfig,
|
||||
labels: BTreeMap<String, String>,
|
||||
scroll_distance: Option<usize>,
|
||||
highlight_trailing_whitespace: bool,
|
||||
whitespace_characters: BTreeMap<char, char>,
|
||||
whitespace_scope: WhitespaceScope,
|
||||
battery_charge: Option<u8>,
|
||||
battery_label: String,
|
||||
git_blame: bool,
|
||||
gutter_style: crate::gutter::GutterStyle,
|
||||
line_blame: Option<(BlameSource, Arc<crate::blame::LineBlame>, String)>,
|
||||
lsp_names: Vec<String>,
|
||||
lsp_label: String,
|
||||
@@ -1833,9 +1879,14 @@ impl Editor {
|
||||
bottom_bar: ChromeLine::bottom(),
|
||||
popup: PopupConfig::default(),
|
||||
labels: BTreeMap::new(),
|
||||
scroll_distance: Some(0),
|
||||
highlight_trailing_whitespace: false,
|
||||
whitespace_characters: BTreeMap::new(),
|
||||
whitespace_scope: WhitespaceScope::All,
|
||||
battery_charge: None,
|
||||
battery_label: String::new(),
|
||||
git_blame: true,
|
||||
gutter_style: crate::gutter::GutterStyle::default(),
|
||||
line_blame: None,
|
||||
lsp_names: Vec::new(),
|
||||
lsp_label: String::new(),
|
||||
@@ -2570,6 +2621,38 @@ impl Editor {
|
||||
.map_or(self.active.view.word_wrap, |diff| diff.word_wrap)
|
||||
}
|
||||
|
||||
pub fn scroll_distance(&self) -> Option<usize> {
|
||||
self.scroll_distance
|
||||
}
|
||||
|
||||
pub(crate) fn set_scroll_distance(&mut self, distance: Option<usize>) {
|
||||
self.scroll_distance = distance;
|
||||
}
|
||||
|
||||
pub(crate) fn highlight_trailing_whitespace(&self) -> bool {
|
||||
self.highlight_trailing_whitespace
|
||||
}
|
||||
|
||||
pub(crate) fn set_highlight_trailing_whitespace(&mut self, enabled: bool) {
|
||||
self.highlight_trailing_whitespace = enabled;
|
||||
}
|
||||
|
||||
pub(crate) fn whitespace_characters(&self) -> &BTreeMap<char, char> {
|
||||
&self.whitespace_characters
|
||||
}
|
||||
|
||||
pub(crate) fn set_whitespace_characters(&mut self, characters: BTreeMap<char, char>) {
|
||||
self.whitespace_characters = characters;
|
||||
}
|
||||
|
||||
pub(crate) fn whitespace_scope(&self) -> WhitespaceScope {
|
||||
self.whitespace_scope
|
||||
}
|
||||
|
||||
pub(crate) fn set_whitespace_scope(&mut self, scope: WhitespaceScope) {
|
||||
self.whitespace_scope = scope;
|
||||
}
|
||||
|
||||
pub(crate) fn set_diff_context_options(&mut self, context_lines: usize, expand_lines: usize) {
|
||||
self.diff_context_options = (context_lines, expand_lines);
|
||||
if let Some(diff) = &mut self.diff {
|
||||
@@ -2581,7 +2664,7 @@ impl Editor {
|
||||
self.diff_display_options = (view_mode, split_axis);
|
||||
}
|
||||
|
||||
pub(crate) fn queue_working_refreshes(&mut self) {
|
||||
pub(crate) fn queue_checkout_refreshes(&mut self, follow_working: bool) {
|
||||
for index in 0..self.tab_order.len() {
|
||||
let tab = self.tab_order[index];
|
||||
let owner = if tab == self.tab_id {
|
||||
@@ -2592,7 +2675,7 @@ impl Editor {
|
||||
if let Some(owner) = owner {
|
||||
self.with_pane(owner, |editor| {
|
||||
if let Some(diff) = &mut editor.diff {
|
||||
diff.queue_working_refresh();
|
||||
diff.queue_checkout_refresh(follow_working);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2631,7 +2714,19 @@ impl Editor {
|
||||
{
|
||||
Some(buffers)
|
||||
}
|
||||
Operation::RefreshWorking { buffers, .. } => Some(buffers),
|
||||
Operation::RefreshCheckout {
|
||||
follow_working: true,
|
||||
buffers,
|
||||
..
|
||||
} if self
|
||||
.diff
|
||||
.as_ref()
|
||||
.or(self.parked_diff.as_ref())
|
||||
.and_then(|diff| diff.after.as_ref())
|
||||
.is_some_and(|after| after.target == RevisionTarget::Working) =>
|
||||
{
|
||||
Some(buffers)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(buffers) = buffers {
|
||||
@@ -2687,6 +2782,25 @@ impl Editor {
|
||||
return self.with_pane(owner, |editor| editor.apply_diff_result(id, result));
|
||||
}
|
||||
self.outstanding_diff_request = None;
|
||||
if self.diff.as_ref().is_some_and(DiffState::refresh_pending)
|
||||
&& !matches!(&result, Ok(Outcome::CheckoutRefresh(None)))
|
||||
{
|
||||
self.finish_completion();
|
||||
self.snippet = None;
|
||||
self.clear_hover();
|
||||
if self.active.view.mode == Mode::Insert {
|
||||
self.finish_insert();
|
||||
self.leave_insert();
|
||||
}
|
||||
}
|
||||
let preserved_view = (self.diff.as_ref().is_some_and(|diff| {
|
||||
diff.refresh_pending()
|
||||
&& diff
|
||||
.after
|
||||
.as_ref()
|
||||
.is_some_and(|after| after.target != RevisionTarget::Working)
|
||||
}) && !matches!(&result, Ok(Outcome::CheckoutRefresh(None))))
|
||||
.then(|| self.active.view.inherit());
|
||||
let Some(diff) = &mut self.diff else {
|
||||
return false;
|
||||
};
|
||||
@@ -2694,15 +2808,10 @@ impl Editor {
|
||||
return false;
|
||||
}
|
||||
let refresh = diff.refresh_pending();
|
||||
let previous = diff.comparison_id;
|
||||
if !diff.apply_result(id, result) {
|
||||
return false;
|
||||
}
|
||||
let replaced = previous != diff.comparison_id;
|
||||
if refresh {
|
||||
if !replaced {
|
||||
return true;
|
||||
}
|
||||
self.pending_lsp_navigation = None;
|
||||
self.diff_bound = None;
|
||||
} else if let Some(files) = &mut diff.files {
|
||||
@@ -2712,7 +2821,34 @@ impl Editor {
|
||||
self.diff.as_ref().and_then(|diff| diff.comparison_id) != Some(role.0)
|
||||
});
|
||||
self.bind_diff_buffer();
|
||||
let pinned_refresh = preserved_view.is_some();
|
||||
if self.active.diff_snapshot.is_some()
|
||||
&& let Some(mut view) = preserved_view
|
||||
{
|
||||
view.clamp(&self.active.lines);
|
||||
self.active.view = view;
|
||||
}
|
||||
let reviewed = self.diff.as_mut().and_then(|diff| {
|
||||
(refresh
|
||||
&& diff
|
||||
.after
|
||||
.as_ref()
|
||||
.is_some_and(|after| after.target != RevisionTarget::Working))
|
||||
.then(|| std::mem::take(&mut diff.reviewed_files))
|
||||
});
|
||||
self.refresh_live_diff();
|
||||
if let Some(reviewed) = reviewed {
|
||||
self.diff
|
||||
.as_mut()
|
||||
.expect("comparison remains open")
|
||||
.reviewed_files = reviewed;
|
||||
}
|
||||
if pinned_refresh {
|
||||
self.diff
|
||||
.as_mut()
|
||||
.expect("comparison remains open")
|
||||
.follow_caret = false;
|
||||
}
|
||||
if refresh || obsolete_snapshot {
|
||||
self.prune_diff_snapshots();
|
||||
}
|
||||
@@ -2969,8 +3105,15 @@ impl Editor {
|
||||
diff.focus
|
||||
};
|
||||
let key = (comparison_id, diff.file, focus);
|
||||
let writable = focus == Focus::After && diff.after_editable();
|
||||
if self.diff_bound.is_some_and(|(id, file, side, buffer)| {
|
||||
(id, file, side) == key && buffer == self.active.id
|
||||
(id, file, side) == key
|
||||
&& buffer == self.active.id
|
||||
&& if writable {
|
||||
self.active.diff_snapshot.is_none() && diff.live_buffer == Some(self.active.id)
|
||||
} else {
|
||||
self.active.diff_snapshot == Some(key)
|
||||
}
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
@@ -2989,7 +3132,6 @@ impl Editor {
|
||||
diff.corresponding_row(from, focus, target.active.row)
|
||||
.map(|row| (row, column))
|
||||
});
|
||||
let writable = focus == Focus::After && diff.after_editable();
|
||||
// Binding a comparison side is not a user departure from its palette.
|
||||
let palette = self.palette.take();
|
||||
if writable {
|
||||
@@ -3132,7 +3274,7 @@ impl Editor {
|
||||
}
|
||||
});
|
||||
if let Some(buffer) = buffer
|
||||
&& diff.working_after_available()
|
||||
&& diff.live_after_available()
|
||||
&& diff.live_revision != Some(buffer.text_revision)
|
||||
{
|
||||
diff.refresh_after(&buffer.lines, buffer.text_revision);
|
||||
@@ -3826,10 +3968,6 @@ impl Editor {
|
||||
return Err("Definition target is binary or non-UTF-8".into());
|
||||
}
|
||||
let writable = focus == Focus::After
|
||||
&& diff
|
||||
.after
|
||||
.as_ref()
|
||||
.is_some_and(|revision| revision.target == RevisionTarget::Working)
|
||||
&& comparison.editable_paths.contains(&file.path)
|
||||
&& file.status != crate::diff::FileStatus::Removed
|
||||
&& file
|
||||
@@ -3865,13 +4003,7 @@ impl Editor {
|
||||
let file = &comparison.files[file_index];
|
||||
let live = if writable {
|
||||
Some(target)
|
||||
} else if focus == Focus::Before
|
||||
&& diff
|
||||
.after
|
||||
.as_ref()
|
||||
.is_some_and(|revision| revision.target == RevisionTarget::Working)
|
||||
&& comparison.editable_paths.contains(&file.path)
|
||||
{
|
||||
} else if focus == Focus::Before && comparison.editable_paths.contains(&file.path) {
|
||||
let identity = path_identity(&comparison.root.join(&file.path)).ok();
|
||||
std::iter::once(&self.active)
|
||||
.chain(self.parked.values())
|
||||
@@ -4615,6 +4747,29 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normal_file_picker(&self) -> Option<&FilePicker> {
|
||||
self.file_picker.as_ref().filter(|_| self.diff.is_none())
|
||||
}
|
||||
|
||||
pub(crate) fn set_picker_repository_changes(
|
||||
&mut self,
|
||||
tab: usize,
|
||||
root: &Path,
|
||||
changes: Vec<crate::repository::WorkingFileChange>,
|
||||
) -> bool {
|
||||
let picker = if tab == self.tab_id {
|
||||
self.file_picker.as_mut().filter(|_| self.diff.is_none())
|
||||
} else {
|
||||
self.tabs
|
||||
.get_mut(&tab)
|
||||
.and_then(|tab| tab.file_picker.as_mut().filter(|_| tab.diff.is_none()))
|
||||
};
|
||||
let Some(picker) = picker.filter(|picker| picker.root == root) else {
|
||||
return false;
|
||||
};
|
||||
picker.set_repository_changes(changes)
|
||||
}
|
||||
|
||||
pub fn file_explorer_side(&self) -> DockSide {
|
||||
self.file_explorer_side
|
||||
}
|
||||
@@ -4816,6 +4971,67 @@ impl Editor {
|
||||
pub fn battery_label(&self) -> &str {
|
||||
&self.battery_label
|
||||
}
|
||||
pub(crate) fn gutter_style(&self) -> crate::gutter::GutterStyle {
|
||||
self.gutter_style
|
||||
}
|
||||
|
||||
pub(crate) fn set_gutter_style(&mut self, style: crate::gutter::GutterStyle) {
|
||||
self.gutter_style = style;
|
||||
}
|
||||
|
||||
pub(crate) fn gutter_change(&self, row: usize) -> Option<crate::gutter::Change> {
|
||||
if self.active.diff_snapshot.is_some() || self.active.reload_error.is_some() {
|
||||
return None;
|
||||
}
|
||||
let changes = self.active.gutter_changes.as_ref()?;
|
||||
// Keep the last completed markers visible while the worker recomputes after edits.
|
||||
if Some(changes.path.as_path()) != self.path() {
|
||||
return None;
|
||||
}
|
||||
changes.lines.get(row).copied().flatten()
|
||||
}
|
||||
|
||||
pub(crate) fn set_gutter_changes(
|
||||
&mut self,
|
||||
buffer: usize,
|
||||
revision: u64,
|
||||
path: &Path,
|
||||
changes: Vec<Option<crate::gutter::Change>>,
|
||||
) -> bool {
|
||||
let buffer = if buffer == self.active.id {
|
||||
&mut self.active
|
||||
} else if let Some(buffer) = self.parked.get_mut(&buffer) {
|
||||
buffer
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
if buffer.diff_snapshot.is_some()
|
||||
|| buffer.text_revision != revision
|
||||
|| buffer.path.as_deref() != Some(path)
|
||||
|| changes.len() > buffer.lines.len()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if buffer
|
||||
.gutter_changes
|
||||
.as_ref()
|
||||
.is_some_and(|old| old.revision == revision && old.path == path && old.lines == changes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let visible = changes.iter().any(Option::is_some)
|
||||
|| buffer
|
||||
.gutter_changes
|
||||
.as_ref()
|
||||
.is_some_and(|old| old.lines.iter().any(Option::is_some));
|
||||
buffer.gutter_changes = Some(GutterChanges {
|
||||
revision,
|
||||
path: path.to_path_buf(),
|
||||
lines: changes,
|
||||
});
|
||||
visible
|
||||
}
|
||||
|
||||
pub fn git_blame(&self) -> bool {
|
||||
self.git_blame
|
||||
}
|
||||
@@ -5474,16 +5690,17 @@ impl Editor {
|
||||
if palette.buffers {
|
||||
palette.entries.clear();
|
||||
for buffer in self.buffers() {
|
||||
let label = sanitize_label(format!(
|
||||
"{}{}{} {}",
|
||||
let label = sanitize_label(buffer.path.map_or_else(
|
||||
|| "[No Name]".into(),
|
||||
|path| path.to_string_lossy().into_owned(),
|
||||
));
|
||||
let searchable = format!(
|
||||
"{}{}{} {label}",
|
||||
buffer.id,
|
||||
if buffer.active { "%" } else { "" },
|
||||
if buffer.dirty { "+" } else { "" },
|
||||
buffer
|
||||
.path
|
||||
.map_or_else(|| Cow::Borrowed("[No Name]"), Path::to_string_lossy),
|
||||
));
|
||||
let searchable = label.to_lowercase();
|
||||
)
|
||||
.to_lowercase();
|
||||
if query
|
||||
.split_whitespace()
|
||||
.all(|token| searchable.contains(token))
|
||||
@@ -5495,6 +5712,11 @@ impl Editor {
|
||||
bindings: Vec::new(),
|
||||
enabled: true,
|
||||
modes: Vec::new(),
|
||||
buffer: Some(PaletteBuffer {
|
||||
id: buffer.id,
|
||||
active: buffer.active,
|
||||
dirty: buffer.dirty,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5543,6 +5765,7 @@ impl Editor {
|
||||
Action::LspDefinition | Action::LspHover | Action::LspDiagnostics
|
||||
) || self.document_actions_available()),
|
||||
modes: modes.to_vec(),
|
||||
buffer: None,
|
||||
});
|
||||
}
|
||||
for feature in self.features.values() {
|
||||
@@ -5562,6 +5785,7 @@ impl Editor {
|
||||
bindings: self.palette_bindings(None, command),
|
||||
enabled: self.active.view.mode == Mode::Normal,
|
||||
modes: vec![Mode::Normal],
|
||||
buffer: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5997,7 +6221,7 @@ impl Editor {
|
||||
}
|
||||
if let Some((operator, prefix)) = self.line_pending.take() {
|
||||
if operator == 'r' {
|
||||
if self.active.view.mode == Mode::Normal
|
||||
if matches!(self.active.view.mode, Mode::Normal | Mode::Visual)
|
||||
&& let Input::Char(target) = input
|
||||
&& (!target.is_control() || target == '\t')
|
||||
{
|
||||
@@ -6005,14 +6229,24 @@ impl Editor {
|
||||
self.set_message("Diff pane is read-only");
|
||||
} else {
|
||||
let before = self.snapshot();
|
||||
self.replace_characters(target, prefix);
|
||||
self.record_native(
|
||||
before,
|
||||
EditRecipe {
|
||||
start: EditStart::Replace {
|
||||
let (ranges, start) = if self.active.view.mode == Mode::Visual {
|
||||
let ranges = self.selections();
|
||||
let selector = self.selection_intent(&ranges);
|
||||
(ranges, EditStart::ReplaceSelection { target, selector })
|
||||
} else {
|
||||
(
|
||||
self.normal_ranges(Action::SubstituteCharacter, prefix),
|
||||
EditStart::Replace {
|
||||
target,
|
||||
count: prefix,
|
||||
},
|
||||
)
|
||||
};
|
||||
self.replace_characters(target, &ranges);
|
||||
self.record_native(
|
||||
before,
|
||||
EditRecipe {
|
||||
start,
|
||||
steps: Vec::new(),
|
||||
},
|
||||
);
|
||||
@@ -6042,6 +6276,8 @@ impl Editor {
|
||||
}),
|
||||
Input::Char('w') if operator == 'd' => Some(Action::DeleteWordForward),
|
||||
Input::Char('b') if operator == 'd' => Some(Action::DeleteWordBackward),
|
||||
Input::Char('w') if operator == 'c' => Some(Action::ChangeWordForward),
|
||||
Input::Char('b') if operator == 'c' => Some(Action::ChangeWordBackward),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(action) = action {
|
||||
@@ -6361,7 +6597,9 @@ impl Editor {
|
||||
| Action::CopyToLineEnd
|
||||
| Action::SubstituteCharacter
|
||||
| Action::DeleteWordForward
|
||||
| Action::DeleteWordBackward => self.edit_normal(action, count),
|
||||
| Action::DeleteWordBackward
|
||||
| Action::ChangeWordForward
|
||||
| Action::ChangeWordBackward => self.edit_normal(action, count),
|
||||
Action::LspCompletion => {
|
||||
if self.active.view.mode == Mode::Insert {
|
||||
return Some(Invocation {
|
||||
@@ -8156,11 +8394,11 @@ impl Editor {
|
||||
.map(|(index, target)| {
|
||||
let cursor = target.active;
|
||||
let end = match action {
|
||||
Action::DeleteWordForward => {
|
||||
self.word_destination(cursor, Action::WordNext, count)
|
||||
Action::DeleteWordForward | Action::ChangeWordForward => {
|
||||
self.word_destination(cursor, Action::DeleteWordForward, count)
|
||||
}
|
||||
Action::DeleteWordBackward => {
|
||||
self.word_destination(cursor, Action::WordPrevious, count)
|
||||
Action::DeleteWordBackward | Action::ChangeWordBackward => {
|
||||
self.word_destination(cursor, Action::DeleteWordBackward, count)
|
||||
}
|
||||
Action::SubstituteCharacter => Cursor {
|
||||
row: cursor.row,
|
||||
@@ -8216,7 +8454,10 @@ impl Editor {
|
||||
let start = EditStart::Normal { action, count };
|
||||
if matches!(
|
||||
action,
|
||||
Action::ChangeToLineEnd | Action::SubstituteCharacter
|
||||
Action::ChangeToLineEnd
|
||||
| Action::SubstituteCharacter
|
||||
| Action::ChangeWordForward
|
||||
| Action::ChangeWordBackward
|
||||
) {
|
||||
self.start_insert(before, start);
|
||||
self.edit_ranges(Action::Change, &ranges);
|
||||
@@ -8235,8 +8476,7 @@ impl Editor {
|
||||
self.reveal_caret();
|
||||
}
|
||||
|
||||
fn replace_characters(&mut self, replacement: char, count: usize) {
|
||||
let ranges = self.normal_ranges(Action::SubstituteCharacter, count);
|
||||
fn replace_characters(&mut self, replacement: char, ranges: &[Selection]) {
|
||||
let offsets = line_offsets(&self.active.lines);
|
||||
let mut bytes = [0; 4];
|
||||
let replacement: &str = replacement.encode_utf8(&mut bytes);
|
||||
@@ -8244,20 +8484,52 @@ impl Editor {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, range)| range.start != range.end)
|
||||
.map(|(target, range)| Edit {
|
||||
target: Some(target),
|
||||
start: cursor_offset(&self.active.lines, &offsets, range.start),
|
||||
end: cursor_offset(&self.active.lines, &offsets, range.end),
|
||||
text: if range.end.column - range.start.column == 1 {
|
||||
Cow::Borrowed(replacement)
|
||||
.map(|(target, range)| {
|
||||
let start = cursor_offset(&self.active.lines, &offsets, range.start);
|
||||
let end = cursor_offset(&self.active.lines, &offsets, range.end);
|
||||
let text = if range.start.row == range.end.row {
|
||||
let count = range.end.column - range.start.column;
|
||||
if count == 1 {
|
||||
Cow::Borrowed(replacement)
|
||||
} else {
|
||||
Cow::Owned(replacement.repeat(count))
|
||||
}
|
||||
} else {
|
||||
Cow::Owned(replacement.repeat(range.end.column - range.start.column))
|
||||
},
|
||||
caret: 0,
|
||||
round_up: false,
|
||||
let mut text = String::with_capacity(end - start);
|
||||
for row in range.start.row..=range.end.row {
|
||||
let first = if row == range.start.row {
|
||||
range.start.column
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let last = if row == range.end.row {
|
||||
range.end.column
|
||||
} else {
|
||||
grapheme_count(&self.active.lines[row])
|
||||
};
|
||||
text.reserve((last - first) * replacement.len());
|
||||
for _ in first..last {
|
||||
text.push_str(replacement);
|
||||
}
|
||||
if row < range.end.row {
|
||||
text.push('\n');
|
||||
}
|
||||
}
|
||||
Cow::Owned(text)
|
||||
};
|
||||
Edit {
|
||||
target: Some(target),
|
||||
start,
|
||||
end,
|
||||
text,
|
||||
caret: 0,
|
||||
round_up: false,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.apply_edits(edits);
|
||||
self.active.view.visual_motions = None;
|
||||
self.active.view.linewise = false;
|
||||
self.active.view.mode = Mode::Normal;
|
||||
self.normalize_targets();
|
||||
}
|
||||
@@ -8794,10 +9066,18 @@ impl Editor {
|
||||
EditStart::Selection {
|
||||
selector: SelectionIntent::Spans(spans),
|
||||
..
|
||||
}
|
||||
| EditStart::ReplaceSelection {
|
||||
selector: SelectionIntent::Spans(spans),
|
||||
..
|
||||
} if self.active.view.mode != Mode::Visual => Some(spans.len()),
|
||||
EditStart::Selection {
|
||||
selector: SelectionIntent::Rows(rows),
|
||||
..
|
||||
}
|
||||
| EditStart::ReplaceSelection {
|
||||
selector: SelectionIntent::Rows(rows),
|
||||
..
|
||||
} if self.active.view.mode != Mode::Visual => Some(rows.len()),
|
||||
_ => None,
|
||||
};
|
||||
@@ -8879,7 +9159,10 @@ impl Editor {
|
||||
clipboard = Some((self.selection_fragments(&ranges), false));
|
||||
let change = matches!(
|
||||
action,
|
||||
Action::ChangeToLineEnd | Action::SubstituteCharacter
|
||||
Action::ChangeToLineEnd
|
||||
| Action::SubstituteCharacter
|
||||
| Action::ChangeWordForward
|
||||
| Action::ChangeWordBackward
|
||||
);
|
||||
self.edit_ranges(if change { Action::Change } else { Action::Cut }, &ranges);
|
||||
if !change {
|
||||
@@ -8887,7 +9170,14 @@ impl Editor {
|
||||
self.normalize_targets();
|
||||
}
|
||||
}
|
||||
EditStart::Replace { target, count } => self.replace_characters(*target, *count),
|
||||
EditStart::Replace { target, count } => {
|
||||
let ranges = self.normal_ranges(Action::SubstituteCharacter, *count);
|
||||
self.replace_characters(*target, &ranges);
|
||||
}
|
||||
EditStart::ReplaceSelection { target, selector } => {
|
||||
let ranges = self.replay_ranges(selector);
|
||||
self.replace_characters(*target, &ranges);
|
||||
}
|
||||
EditStart::Lines { change, count } => {
|
||||
let ranges = self.line_selections(*count);
|
||||
clipboard = Some((self.selection_fragments(&ranges), true));
|
||||
@@ -9249,9 +9539,16 @@ impl Editor {
|
||||
action,
|
||||
Action::BigWordNext | Action::BigWordPrevious | Action::BigWordEnd
|
||||
);
|
||||
let backward = matches!(action, Action::WordPrevious | Action::BigWordPrevious);
|
||||
let start = if backward { 0 } else { row };
|
||||
let end = if backward {
|
||||
let backward = matches!(
|
||||
action,
|
||||
Action::WordPrevious | Action::BigWordPrevious | Action::DeleteWordBackward
|
||||
);
|
||||
let line_local = matches!(
|
||||
action,
|
||||
Action::DeleteWordForward | Action::DeleteWordBackward
|
||||
);
|
||||
let start = if line_local || !backward { row } else { 0 };
|
||||
let end = if line_local || backward {
|
||||
row + 1
|
||||
} else {
|
||||
self.active.lines.len()
|
||||
@@ -9261,17 +9558,12 @@ impl Editor {
|
||||
.map(move |(_, byte, kind)| (source_row, byte, kind))
|
||||
});
|
||||
let destination = match action {
|
||||
Action::WordPrevious | Action::BigWordPrevious => {
|
||||
Action::WordPrevious | Action::BigWordPrevious | Action::DeleteWordBackward => {
|
||||
let mut tokens = tokens
|
||||
.rev()
|
||||
.skip_while(|(r, b, _)| (*r, *b) >= (row, byte))
|
||||
.peekable();
|
||||
let fallback = (
|
||||
self.navigable_rows(0, self.active.lines.len())
|
||||
.next()
|
||||
.unwrap_or(row),
|
||||
0,
|
||||
);
|
||||
let fallback = (self.navigable_rows(start, end).next().unwrap_or(row), 0);
|
||||
let mut destination = fallback;
|
||||
for _ in 0..count {
|
||||
let Some(mut first) = tokens.find(|(_, _, kind)| *kind != WordKind::Whitespace)
|
||||
@@ -9296,13 +9588,13 @@ impl Editor {
|
||||
return cursor;
|
||||
};
|
||||
let mut current = first.2;
|
||||
let last = self
|
||||
.navigable_rows(0, self.active.lines.len())
|
||||
.next_back()
|
||||
.unwrap_or(row);
|
||||
let last = self.navigable_rows(start, end).next_back().unwrap_or(row);
|
||||
let mut destination = (last, self.active.lines[last].len());
|
||||
for _ in 0..count {
|
||||
let next_start = matches!(action, Action::WordNext | Action::BigWordNext);
|
||||
let next_start = matches!(
|
||||
action,
|
||||
Action::WordNext | Action::BigWordNext | Action::DeleteWordForward
|
||||
);
|
||||
if next_start && current != WordKind::EmptyLine {
|
||||
while tokens.peek().is_some_and(|(_, _, kind)| *kind == current) {
|
||||
tokens.next();
|
||||
@@ -10217,7 +10509,7 @@ fn validate_text(text: &str) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_newlines(text: &str) -> Cow<'_, str> {
|
||||
pub(crate) fn normalize_newlines(text: &str) -> Cow<'_, str> {
|
||||
if !text.contains('\r') {
|
||||
return Cow::Borrowed(text);
|
||||
}
|
||||
@@ -10325,7 +10617,7 @@ fn lsp_navigation_position(
|
||||
Ok(TextPosition { row, column })
|
||||
}
|
||||
|
||||
fn path_identity(path: &Path) -> io::Result<PathBuf> {
|
||||
pub(crate) fn path_identity(path: &Path) -> io::Result<PathBuf> {
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_owned()
|
||||
} else {
|
||||
@@ -12765,14 +13057,14 @@ mod tests {
|
||||
let visible_tab = editor.tab_id();
|
||||
let visible_pane = editor.pane_id();
|
||||
let cursor = editor.cursor();
|
||||
editor.queue_working_refreshes();
|
||||
editor.queue_checkout_refreshes(true);
|
||||
let (tab, request, operation) = editor.take_next_diff_request().unwrap();
|
||||
assert_eq!(tab, hidden_tab);
|
||||
assert!(
|
||||
matches!(operation, Operation::RefreshWorking { comparison_id, .. }
|
||||
matches!(operation, Operation::RefreshCheckout { comparison_id, .. }
|
||||
if comparison_id == old_comparison)
|
||||
);
|
||||
assert!(!editor.apply_diff_result(request, Ok(Outcome::WorkingRefresh(None))));
|
||||
assert!(!editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(None))));
|
||||
assert_eq!(
|
||||
editor.tab_diff(hidden_tab).unwrap().comparison_id,
|
||||
Some(old_comparison)
|
||||
@@ -12782,12 +13074,12 @@ mod tests {
|
||||
assert_eq!(editor.buffer_id(), old_snapshot);
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
|
||||
editor.queue_working_refreshes();
|
||||
editor.queue_checkout_refreshes(true);
|
||||
let (_, request, _) = editor.take_next_diff_request().unwrap();
|
||||
let replacement = editor.with_pane(owner, |editor| {
|
||||
editor.diff.as_mut().unwrap().comparison.take().unwrap()
|
||||
});
|
||||
assert!(editor.apply_diff_result(request, Ok(Outcome::WorkingRefresh(Some(replacement)))));
|
||||
assert!(editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(replacement)))));
|
||||
assert_eq!(
|
||||
editor.tab_diff(hidden_tab).unwrap().comparison_id,
|
||||
Some(request)
|
||||
@@ -12801,7 +13093,7 @@ mod tests {
|
||||
.apply(vec![Effect::Insert("rejected".into())])
|
||||
.is_err()
|
||||
);
|
||||
assert!(!editor.apply_diff_result(request, Ok(Outcome::WorkingRefresh(None))));
|
||||
assert!(!editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(None))));
|
||||
|
||||
editor.with_pane(owner, |editor| {
|
||||
assert_ne!(editor.buffer_id(), old_snapshot);
|
||||
@@ -12831,7 +13123,7 @@ mod tests {
|
||||
let mut target = lazy_lsp_target(&mut editor);
|
||||
let old_comparison = editor.diff().unwrap().comparison_id.unwrap();
|
||||
let virtual_root = editor.diff().unwrap().snapshot_roots[0].clone().unwrap();
|
||||
editor.queue_working_refreshes();
|
||||
editor.queue_checkout_refreshes(true);
|
||||
let (_, refresh, _) = editor.take_next_diff_request().unwrap();
|
||||
let loaded_before = target.before.clone();
|
||||
let loaded_after = target.after.clone();
|
||||
@@ -12863,7 +13155,7 @@ mod tests {
|
||||
let changed = request_kind != "noop_navigation";
|
||||
let outcome = if changed { Some(replacement) } else { None };
|
||||
assert_eq!(
|
||||
editor.apply_diff_result(refresh, Ok(Outcome::WorkingRefresh(outcome))),
|
||||
editor.apply_diff_result(refresh, Ok(Outcome::CheckoutRefresh(outcome))),
|
||||
changed
|
||||
);
|
||||
if changed {
|
||||
@@ -13817,6 +14109,83 @@ mod tests {
|
||||
assert!(editor.dirty());
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.lines(), ["beta"]);
|
||||
for refresh_error in [false, true] {
|
||||
let path = directory.path("file.txt");
|
||||
fs::write(&path, "original").unwrap();
|
||||
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
|
||||
historical_diff(
|
||||
&mut editor,
|
||||
&directory.0,
|
||||
vec!["before".into()],
|
||||
vec!["original".into()],
|
||||
);
|
||||
let endpoints = (
|
||||
editor.diff().unwrap().before.clone(),
|
||||
editor.diff().unwrap().after.clone(),
|
||||
);
|
||||
editor.queue_checkout_refreshes(false);
|
||||
let (_, request, _) = editor.take_next_diff_request().unwrap();
|
||||
let mut promoted = editor.diff.as_mut().unwrap().comparison.take().unwrap();
|
||||
promoted.editable_paths.insert("file.txt".into());
|
||||
editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(promoted))));
|
||||
editor.diff.as_mut().unwrap().focus = Focus::After;
|
||||
editor.bind_diff_buffer();
|
||||
assert!(editor.diff_editing());
|
||||
let live = editor.buffer_id();
|
||||
type_text(&mut editor, "ix");
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
let comparison_id = editor.diff().unwrap().comparison_id;
|
||||
editor.queue_checkout_refreshes(false);
|
||||
let (_, request, _) = editor.take_next_diff_request().unwrap();
|
||||
if refresh_error {
|
||||
assert!(editor.apply_diff_result(request, Err("checkout unavailable".into())));
|
||||
assert_eq!(editor.diff().unwrap().comparison_id, comparison_id);
|
||||
} else {
|
||||
let mut demoted = editor.diff.as_mut().unwrap().comparison.take().unwrap();
|
||||
demoted.editable_paths.clear();
|
||||
demoted.files[0].after = vec!["original".into()];
|
||||
demoted.files[0].after_eof = false;
|
||||
demoted.files[0].hunks = crate::diff::line_hunks(
|
||||
&demoted.files[0].before,
|
||||
false,
|
||||
&demoted.files[0].after,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(demoted))),)
|
||||
);
|
||||
}
|
||||
assert!(!editor.diff_editable());
|
||||
assert_eq!(editor.lines(), ["original"]);
|
||||
assert_eq!(editor.buffer(live).unwrap().lines, ["xoriginal"]);
|
||||
assert!(editor.buffer(live).unwrap().dirty());
|
||||
assert_eq!(editor.buffer(live).unwrap().view.mode, Mode::Normal);
|
||||
assert!(
|
||||
editor
|
||||
.apply(vec![Effect::Insert("blocked".into())])
|
||||
.is_err()
|
||||
);
|
||||
editor.execute("w");
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "original");
|
||||
editor.queue_checkout_refreshes(false);
|
||||
let (_, request, _) = editor.take_next_diff_request().unwrap();
|
||||
let mut promoted = editor.diff.as_mut().unwrap().comparison.take().unwrap();
|
||||
promoted.editable_paths.insert("file.txt".into());
|
||||
editor.apply_diff_result(request, Ok(Outcome::CheckoutRefresh(Some(promoted))));
|
||||
assert!(editor.diff_editing());
|
||||
assert!(editor.diff().unwrap().error.is_none());
|
||||
assert_eq!(editor.buffer_id(), live);
|
||||
assert_eq!(editor.lines(), ["xoriginal"]);
|
||||
assert_eq!(
|
||||
(
|
||||
editor.diff().unwrap().before.clone(),
|
||||
editor.diff().unwrap().after.clone(),
|
||||
),
|
||||
endpoints,
|
||||
);
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.lines(), ["original"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -15884,7 +16253,9 @@ mod tests {
|
||||
let carets = editor.carets();
|
||||
let revision = editor.text_revision();
|
||||
fixture.fail("fail-write", true);
|
||||
for keys in ["yy", "2y3y", "2dd", "cc", "C", "D", "Y", "s", "d2w", "d2b"] {
|
||||
for keys in [
|
||||
"yy", "2y3y", "2dd", "cc", "C", "D", "Y", "s", "d2w", "d2b", "c2w", "c2b",
|
||||
] {
|
||||
type_text(&mut editor, keys);
|
||||
assert_eq!(editor.context().text, "Xone\ntwo\nthree");
|
||||
assert_eq!(editor.carets(), carets);
|
||||
@@ -19533,6 +19904,8 @@ mod tests {
|
||||
("4s", [(0, 0), (0, 2)], "!o three four\nkeep"),
|
||||
("d2w", [(0, 0), (0, 4)], "four\nkeep"),
|
||||
("d2b", [(0, 8), (0, 14)], "four\nkeep"),
|
||||
("c2w", [(0, 0), (0, 4)], "!four\nkeep"),
|
||||
("c2b", [(0, 8), (0, 14)], "!four\nkeep"),
|
||||
("4rX", [(0, 0), (0, 2)], "XXXXXXo three four\nkeep"),
|
||||
] {
|
||||
let mut editor = editor();
|
||||
@@ -19601,7 +19974,7 @@ mod tests {
|
||||
let targets = editor.active.view.targets.clone();
|
||||
let undo = editor.active.undo.len();
|
||||
let clipboard = editor.clipboard.clone();
|
||||
for keys in ["C", "D", "s", "2r", "d2w", "d2b"] {
|
||||
for keys in ["C", "D", "s", "2r", "d2w", "d2b", "c2w", "c2b"] {
|
||||
type_text(&mut editor, keys);
|
||||
assert_eq!(editor.context().text, text, "{keys}");
|
||||
assert_eq!(editor.active.view.targets, targets, "{keys}");
|
||||
@@ -19624,6 +19997,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_visual_replacement_preserves_snapshot_selections_and_history() {
|
||||
let directory = Directory::new();
|
||||
let mut editor = editor();
|
||||
historical_diff(
|
||||
&mut editor,
|
||||
&directory.0,
|
||||
vec!["one two three".into()],
|
||||
vec!["four five six".into()],
|
||||
);
|
||||
for focus in [Focus::Before, Focus::After] {
|
||||
if editor.diff().unwrap().focus != focus {
|
||||
editor.handle(Input::Char('\t'));
|
||||
}
|
||||
type_text(&mut editor, "vl");
|
||||
let text = editor.context().text;
|
||||
let targets = editor.active.view.targets.clone();
|
||||
let undo = editor.active.undo.len();
|
||||
let clipboard = editor.clipboard.clone();
|
||||
for pending in [false, true] {
|
||||
if pending {
|
||||
editor.line_pending = Some(('r', 1));
|
||||
} else {
|
||||
editor.handle(Input::Char('r'));
|
||||
}
|
||||
editor.handle(Input::Char('X'));
|
||||
assert_eq!(editor.context().text, text);
|
||||
assert_eq!(editor.active.view.targets, targets);
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
assert_eq!(editor.line_operator_pending(), None);
|
||||
assert_eq!(editor.clipboard, clipboard);
|
||||
assert_eq!(editor.active.undo.len(), undo);
|
||||
}
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_carets(editor: &mut Editor, positions: &[(usize, usize)], primary: usize) {
|
||||
editor.active.view.mode = Mode::Normal;
|
||||
editor.active.view.visual_motions = None;
|
||||
|
||||
+404
-1
@@ -1,3 +1,5 @@
|
||||
use crate::diff::FileStatus;
|
||||
use crate::repository::WorkingFileChange;
|
||||
use ignore::WalkBuilder;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
@@ -39,6 +41,8 @@ pub struct FileRow<'a> {
|
||||
pub label: &'a str,
|
||||
/// Original virtual-catalog input index; absent for scanned entries and directories.
|
||||
pub catalog_index: Option<usize>,
|
||||
pub status: Option<FileStatus>,
|
||||
pub old_path: Option<&'a Path>,
|
||||
pub is_dir: bool,
|
||||
pub depth: usize,
|
||||
pub expanded: bool,
|
||||
@@ -51,6 +55,9 @@ struct Node {
|
||||
search_label: String,
|
||||
catalog_index: Option<usize>,
|
||||
is_dir: bool,
|
||||
status: Option<FileStatus>,
|
||||
old_path: Option<PathBuf>,
|
||||
virtual_entry: bool,
|
||||
parent: Option<usize>,
|
||||
children: Vec<usize>,
|
||||
}
|
||||
@@ -86,12 +93,16 @@ pub struct FilePicker {
|
||||
tree_for_node: Vec<usize>,
|
||||
tree_roots: Vec<usize>,
|
||||
rows: Vec<VisibleRow>,
|
||||
viewport_revision: u64,
|
||||
expanded: HashSet<usize>,
|
||||
filter_collapsed: HashSet<usize>,
|
||||
saved_selection: [Option<usize>; 2],
|
||||
filtered_selection: [Option<usize>; 2],
|
||||
tree_searching: bool,
|
||||
warning: Option<String>,
|
||||
repository_changes: Vec<WorkingFileChange>,
|
||||
has_repository_changes: bool,
|
||||
has_virtual_entries: bool,
|
||||
}
|
||||
|
||||
fn label(path: &Path) -> String {
|
||||
@@ -101,6 +112,28 @@ fn label(path: &Path) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn safe_repository_path(path: &Path) -> bool {
|
||||
!path.as_os_str().is_empty()
|
||||
&& path.components().all(|component| {
|
||||
matches!(component, std::path::Component::Normal(name) if name != ".git" && name != ".jj")
|
||||
})
|
||||
}
|
||||
|
||||
fn missing_repository_path(root: &Path, path: &Path) -> bool {
|
||||
// A deleted leaf may be absent with all its parents, but never walk through symlinks.
|
||||
let mut current = root.to_owned();
|
||||
for component in path.components() {
|
||||
current.push(component);
|
||||
match fs::symlink_metadata(¤t) {
|
||||
Ok(metadata) if metadata.is_dir() => {}
|
||||
Ok(_) => return false,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return true,
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
impl FilePicker {
|
||||
pub fn scan(root: PathBuf, presentation: FilePresentation, view: FileView) -> io::Result<Self> {
|
||||
if !fs::metadata(&root)?.is_dir() {
|
||||
@@ -156,6 +189,9 @@ impl FilePicker {
|
||||
search_label: label.to_lowercase(),
|
||||
label,
|
||||
catalog_index: None,
|
||||
status: None,
|
||||
old_path: None,
|
||||
virtual_entry: false,
|
||||
is_dir: entry.file_type().unwrap().is_dir(),
|
||||
parent: None,
|
||||
children: Vec::new(),
|
||||
@@ -186,6 +222,9 @@ impl FilePicker {
|
||||
search_label: label.to_lowercase(),
|
||||
label,
|
||||
catalog_index,
|
||||
status: None,
|
||||
old_path: None,
|
||||
virtual_entry: false,
|
||||
is_dir,
|
||||
parent: None,
|
||||
children: Vec::new(),
|
||||
@@ -255,16 +294,20 @@ impl FilePicker {
|
||||
focused: true,
|
||||
error: warning.clone(),
|
||||
tree_for_node: vec![0; nodes.len()],
|
||||
has_virtual_entries: nodes.iter().any(|node| node.virtual_entry),
|
||||
nodes,
|
||||
tree: Vec::new(),
|
||||
tree_roots: Vec::new(),
|
||||
rows: Vec::new(),
|
||||
viewport_revision: 0,
|
||||
expanded: HashSet::new(),
|
||||
filter_collapsed: HashSet::new(),
|
||||
saved_selection: [None, None],
|
||||
filtered_selection: [None, None],
|
||||
tree_searching: false,
|
||||
warning,
|
||||
repository_changes: Vec::new(),
|
||||
has_repository_changes: false,
|
||||
};
|
||||
for root in roots {
|
||||
let tree = picker.compact(root, None);
|
||||
@@ -305,6 +348,10 @@ impl FilePicker {
|
||||
tree
|
||||
}
|
||||
|
||||
pub(crate) fn viewport_revision(&self) -> u64 {
|
||||
self.viewport_revision
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl ExactSizeIterator<Item = FileRow<'_>> + '_ {
|
||||
self.rows.iter().map(|row| {
|
||||
let node = &self.nodes[row.node];
|
||||
@@ -315,6 +362,8 @@ impl FilePicker {
|
||||
.map_or(node.label.as_str(), |tree| self.tree[tree].label.as_str()),
|
||||
is_dir: node.is_dir,
|
||||
catalog_index: node.catalog_index,
|
||||
status: node.status,
|
||||
old_path: node.old_path.as_deref(),
|
||||
depth: row.depth,
|
||||
expanded: row.expanded,
|
||||
}
|
||||
@@ -499,6 +548,8 @@ impl FilePicker {
|
||||
}
|
||||
|
||||
fn project(&mut self, selected: Option<usize>, select_match: bool) {
|
||||
static NEXT_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
self.viewport_revision = NEXT_REVISION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let query = self.query.to_lowercase();
|
||||
let tokens: Vec<_> = query.split_whitespace().collect();
|
||||
let matches = |node: &Node| tokens.iter().all(|token| node.search_label.contains(token));
|
||||
@@ -721,11 +772,158 @@ impl FilePicker {
|
||||
|
||||
pub(crate) fn refresh(&mut self) {
|
||||
match Self::scan(self.root.clone(), self.presentation, self.view) {
|
||||
Ok(replacement) => self.replace_catalog(replacement),
|
||||
Ok(mut replacement) => {
|
||||
replacement.set_repository_changes(self.repository_changes.clone());
|
||||
self.replace_catalog(replacement);
|
||||
}
|
||||
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn has_repository_changes(&self) -> bool {
|
||||
self.has_repository_changes
|
||||
}
|
||||
|
||||
pub(crate) fn set_repository_changes(&mut self, changes: Vec<WorkingFileChange>) -> bool {
|
||||
if !self.has_virtual_entries && changes == self.repository_changes {
|
||||
return false;
|
||||
}
|
||||
if self.nodes.iter().any(|node| {
|
||||
node.virtual_entry
|
||||
&& !node.is_dir
|
||||
&& fs::symlink_metadata(self.root.join(&node.path))
|
||||
.is_ok_and(|metadata| metadata.is_file())
|
||||
}) {
|
||||
// Reuse the scanner's ignore/symlink rules when a deleted file returns.
|
||||
match Self::scan(self.root.clone(), self.presentation, self.view) {
|
||||
Ok(mut replacement) => {
|
||||
replacement.set_repository_changes(changes);
|
||||
replacement.error = self.error.clone().or(replacement.error);
|
||||
self.replace_catalog(replacement);
|
||||
return true;
|
||||
}
|
||||
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
|
||||
}
|
||||
}
|
||||
let changes: Vec<_> = changes
|
||||
.into_iter()
|
||||
.filter(|change| safe_repository_path(&change.path))
|
||||
.map(|mut change| {
|
||||
change.old_path = change.old_path.filter(|path| safe_repository_path(path));
|
||||
change
|
||||
})
|
||||
.collect();
|
||||
let by_path: HashMap<_, _> = changes
|
||||
.iter()
|
||||
.map(|change| (change.path.as_path(), change))
|
||||
.collect();
|
||||
let scanned: HashSet<_> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| !node.virtual_entry)
|
||||
.map(|node| (node.path.as_path(), node.is_dir))
|
||||
.collect();
|
||||
let mut virtual_files = HashSet::new();
|
||||
let mut virtual_dirs = HashSet::new();
|
||||
for change in &changes {
|
||||
if change.status != FileStatus::Removed
|
||||
|| scanned.contains(&(change.path.as_path(), false))
|
||||
|| !missing_repository_path(&self.root, &change.path)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
virtual_files.insert(change.path.clone());
|
||||
for parent in change.path.ancestors().skip(1) {
|
||||
if parent.as_os_str().is_empty() {
|
||||
break;
|
||||
}
|
||||
if !scanned.contains(&(parent, true)) && !virtual_dirs.contains(parent) {
|
||||
virtual_dirs.insert(parent.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
let topology_changed = self.nodes.iter().filter(|node| node.virtual_entry).count()
|
||||
!= virtual_files.len() + virtual_dirs.len()
|
||||
|| self
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| node.virtual_entry)
|
||||
.any(|node| {
|
||||
!(if node.is_dir {
|
||||
&virtual_dirs
|
||||
} else {
|
||||
&virtual_files
|
||||
})
|
||||
.contains(node.path.as_path())
|
||||
});
|
||||
let mut metadata_changed = false;
|
||||
for node in &mut self.nodes {
|
||||
let change = (!node.is_dir && node.catalog_index.is_none())
|
||||
.then(|| by_path.get(node.path.as_path()))
|
||||
.flatten();
|
||||
let status = change.map(|change| change.status);
|
||||
let old_path = change.and_then(|change| change.old_path.as_ref());
|
||||
metadata_changed |= node.status != status || node.old_path.as_ref() != old_path;
|
||||
node.status = status;
|
||||
if node.old_path.as_ref() != old_path {
|
||||
node.old_path = old_path.cloned();
|
||||
}
|
||||
}
|
||||
if topology_changed {
|
||||
let mut nodes: Vec<_> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| !node.virtual_entry)
|
||||
.map(|node| Node {
|
||||
path: node.path.clone(),
|
||||
label: node.label.clone(),
|
||||
search_label: node.search_label.clone(),
|
||||
catalog_index: node.catalog_index,
|
||||
status: node.status,
|
||||
old_path: node.old_path.clone(),
|
||||
virtual_entry: false,
|
||||
is_dir: node.is_dir,
|
||||
parent: None,
|
||||
children: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
nodes.extend(
|
||||
virtual_files
|
||||
.into_iter()
|
||||
.map(|path| (path, false))
|
||||
.chain(virtual_dirs.into_iter().map(|path| (path, true)))
|
||||
.map(|(path, is_dir)| {
|
||||
let label = label(&path);
|
||||
let change = (!is_dir).then(|| by_path.get(path.as_path())).flatten();
|
||||
Node {
|
||||
status: change.map(|change| change.status),
|
||||
old_path: change.and_then(|change| change.old_path.clone()),
|
||||
path,
|
||||
search_label: label.to_lowercase(),
|
||||
label,
|
||||
catalog_index: None,
|
||||
virtual_entry: true,
|
||||
is_dir,
|
||||
parent: None,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}),
|
||||
);
|
||||
let mut replacement = Self::from_nodes(
|
||||
self.root.clone(),
|
||||
nodes,
|
||||
self.presentation,
|
||||
self.view,
|
||||
self.warning.clone(),
|
||||
);
|
||||
replacement.error = self.error.clone();
|
||||
self.replace_catalog(replacement);
|
||||
}
|
||||
self.has_repository_changes = self.nodes.iter().any(|node| node.status.is_some());
|
||||
self.repository_changes = changes;
|
||||
topology_changed || metadata_changed
|
||||
}
|
||||
|
||||
pub(crate) fn replace_paths(&mut self, paths: impl IntoIterator<Item = (usize, PathBuf)>) {
|
||||
self.replace_catalog(Self::from_paths(self.root.clone(), paths));
|
||||
}
|
||||
@@ -835,6 +1033,7 @@ mod tests {
|
||||
assert!(!rows.is_empty());
|
||||
for row in rows {
|
||||
assert_eq!(row.catalog_index, expected_index(&row));
|
||||
assert!(row.status.is_none() && row.old_path.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -881,9 +1080,213 @@ mod tests {
|
||||
picker.set_view(view);
|
||||
picker.reveal_path(Path::new("nested/file.txt"));
|
||||
assert!(picker.entries().all(|row| row.catalog_index.is_none()));
|
||||
assert!(
|
||||
picker
|
||||
.entries()
|
||||
.all(|row| row.status.is_none() && row.old_path.is_none())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn repository_change(
|
||||
path: &str,
|
||||
status: FileStatus,
|
||||
old_path: Option<&str>,
|
||||
) -> WorkingFileChange {
|
||||
WorkingFileChange {
|
||||
path: path.into(),
|
||||
status,
|
||||
old_path: old_path.map(PathBuf::from),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_metadata_keeps_clean_files_selection_and_filtered_views() {
|
||||
let directory = Directory::new();
|
||||
for name in ["src/clean.rs", "src/modified.rs", "added.rs", "renamed.rs"] {
|
||||
directory.put(name, "");
|
||||
}
|
||||
let mut picker = directory.scan();
|
||||
picker.presentation = FilePresentation::Docked;
|
||||
picker.set_view(FileView::Tree);
|
||||
picker.reveal_path(Path::new("src/clean.rs"));
|
||||
picker.query = "src".into();
|
||||
picker.refilter();
|
||||
picker.focused = false;
|
||||
let changes = vec![
|
||||
repository_change("src/modified.rs", FileStatus::Modified, None),
|
||||
repository_change("added.rs", FileStatus::Added, None),
|
||||
repository_change("renamed.rs", FileStatus::Renamed, Some("old.rs")),
|
||||
repository_change("vanished/deep/deleted.rs", FileStatus::Removed, None),
|
||||
];
|
||||
assert!(picker.set_repository_changes(changes.clone()));
|
||||
assert!(!picker.set_repository_changes(changes));
|
||||
assert_eq!(selected_path(&picker), Path::new("src/clean.rs"));
|
||||
assert_eq!(picker.query, "src");
|
||||
assert!(!picker.focused);
|
||||
assert_eq!(picker.presentation, FilePresentation::Docked);
|
||||
assert!(picker.has_repository_changes());
|
||||
for view in [FileView::List, FileView::Tree] {
|
||||
picker.set_view(view);
|
||||
for query in ["", "src", "modified.rs", "deleted.rs"] {
|
||||
picker.query = query.into();
|
||||
picker.refilter();
|
||||
let rows: Vec<_> = picker.entries().collect();
|
||||
assert!(!rows.is_empty());
|
||||
for row in rows {
|
||||
assert!(row.catalog_index.is_none());
|
||||
if row.is_dir || row.path == Path::new("src/clean.rs") {
|
||||
assert!(row.status.is_none() && row.old_path.is_none());
|
||||
} else {
|
||||
let status = match row.path.to_str().unwrap() {
|
||||
"src/modified.rs" => FileStatus::Modified,
|
||||
"added.rs" => FileStatus::Added,
|
||||
"renamed.rs" => FileStatus::Renamed,
|
||||
"vanished/deep/deleted.rs" => FileStatus::Removed,
|
||||
other => panic!("unexpected {other}"),
|
||||
};
|
||||
assert_eq!(row.status, Some(status));
|
||||
assert_eq!(
|
||||
row.old_path,
|
||||
(status == FileStatus::Renamed).then(|| Path::new("old.rs"))
|
||||
);
|
||||
}
|
||||
}
|
||||
if query.is_empty() || query == "src" {
|
||||
assert!(
|
||||
picker
|
||||
.entries()
|
||||
.any(|row| row.path == Path::new("src/clean.rs"))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
picker.set_view(FileView::List);
|
||||
picker.clear_filter();
|
||||
picker.reveal_path(Path::new("src/clean.rs"));
|
||||
assert!(picker.set_repository_changes(Vec::new()));
|
||||
assert_eq!(selected_path(&picker), Path::new("src/clean.rs"));
|
||||
assert!(!picker.has_repository_changes());
|
||||
assert_eq!(picker.entries().len(), 4);
|
||||
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
|
||||
assert!(
|
||||
picker
|
||||
.entries()
|
||||
.all(|row| row.status.is_none() && row.old_path.is_none())
|
||||
);
|
||||
assert!(!picker.set_repository_changes(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_catalog_leaves_survive_refresh_and_restored_files_survive_clear() {
|
||||
let directory = Directory::new();
|
||||
directory.put("keep.txt", "");
|
||||
let mut picker = directory.scan();
|
||||
let deleted = Path::new("lost/deep/deleted.txt");
|
||||
picker.set_repository_changes(vec![repository_change(
|
||||
"lost/deep/deleted.txt",
|
||||
FileStatus::Removed,
|
||||
None,
|
||||
)]);
|
||||
picker.set_view(FileView::Tree);
|
||||
assert_eq!(
|
||||
picker.entries().find(|row| row.is_dir).unwrap().label,
|
||||
"lost/deep"
|
||||
);
|
||||
picker.reveal_path(deleted);
|
||||
picker.query = "deleted".into();
|
||||
picker.refilter();
|
||||
picker.start_search();
|
||||
picker.focused = false;
|
||||
picker.refresh();
|
||||
assert_eq!(selected_path(&picker), deleted);
|
||||
assert!(picker.searching && !picker.focused);
|
||||
assert_eq!(picker.query, "deleted");
|
||||
assert_eq!(picker.accept(), Some(directory.0.join(deleted)));
|
||||
assert!(!directory.0.join(deleted).exists());
|
||||
assert_eq!(
|
||||
picker.entries().find(|row| !row.is_dir).unwrap().status,
|
||||
Some(FileStatus::Removed)
|
||||
);
|
||||
directory.put("lost/deep/deleted.txt", "restored");
|
||||
assert!(picker.set_repository_changes(vec![repository_change(
|
||||
"lost/deep/deleted.txt",
|
||||
FileStatus::Removed,
|
||||
None,
|
||||
)]));
|
||||
picker.refresh();
|
||||
assert_eq!(selected_path(&picker), deleted);
|
||||
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
|
||||
assert!(picker.set_repository_changes(Vec::new()));
|
||||
assert_eq!(selected_path(&picker), deleted);
|
||||
assert_eq!(picker.accept(), Some(directory.0.join(deleted)));
|
||||
assert_eq!(
|
||||
fs::read_to_string(directory.0.join(deleted)).unwrap(),
|
||||
"restored"
|
||||
);
|
||||
assert!(picker.entries().all(|row| row.status.is_none()));
|
||||
fs::remove_dir_all(directory.0.join("lost")).unwrap();
|
||||
picker.refresh();
|
||||
picker.set_repository_changes(vec![repository_change(
|
||||
"lost/deep/deleted.txt",
|
||||
FileStatus::Removed,
|
||||
None,
|
||||
)]);
|
||||
picker.reveal_path(deleted);
|
||||
picker.set_repository_changes(Vec::new());
|
||||
assert!(picker.entries().len() == 0);
|
||||
picker.clear_filter();
|
||||
assert_eq!(paths(&picker), [PathBuf::from("keep.txt")]);
|
||||
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_metadata_cannot_introduce_unsafe_or_existing_excluded_paths() {
|
||||
let directory = Directory::new();
|
||||
directory.put(".gitignore", "ignored/\n");
|
||||
directory.put("ignored/hidden.txt", "");
|
||||
directory.put("directory/keep.txt", "");
|
||||
let mut picker = directory.scan();
|
||||
let mut changes: Vec<_> = [
|
||||
"",
|
||||
".",
|
||||
"../outside.txt",
|
||||
"directory/../../outside.txt",
|
||||
".git/secret",
|
||||
"nested/.jj/secret",
|
||||
"/outside.txt",
|
||||
"directory",
|
||||
"ignored/hidden.txt",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|path| repository_change(path, FileStatus::Removed, None))
|
||||
.collect();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&directory.0, directory.0.join("link")).unwrap();
|
||||
changes.push(repository_change(
|
||||
"link/missing.txt",
|
||||
FileStatus::Removed,
|
||||
None,
|
||||
));
|
||||
}
|
||||
changes.push(repository_change("missing.txt", FileStatus::Removed, None));
|
||||
picker.set_repository_changes(changes);
|
||||
assert_eq!(
|
||||
paths(&picker),
|
||||
[".gitignore", "directory/keep.txt", "missing.txt"].map(PathBuf::from)
|
||||
);
|
||||
assert_eq!(
|
||||
picker.entries().filter(|row| row.status.is_some()).count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
picker.entries().last().unwrap().status,
|
||||
Some(FileStatus::Removed)
|
||||
);
|
||||
assert!(!directory.0.join("missing.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_ignore_negation_hidden_files_and_vcs_metadata() {
|
||||
let directory = Directory::new();
|
||||
|
||||
+885
@@ -0,0 +1,885 @@
|
||||
//! Read-only source-line changes against Git HEAD or a single Jujutsu @- parent.
|
||||
use crate::{
|
||||
blame::{git, git_without_literal_pathspecs, run, run_status},
|
||||
diff::line_hunks,
|
||||
editor::normalize_newlines,
|
||||
repository::{
|
||||
WorkingFileChange, WorkingFileReader, jj_command, jj_file_pattern, safe_path, store_for_jj,
|
||||
},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
sync::{
|
||||
Arc, Condvar, Mutex,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
};
|
||||
|
||||
const FILE_LIMIT: usize = 16 * 1024 * 1024;
|
||||
const BATCH_LIMIT: usize = 64 * 1024 * 1024;
|
||||
const FILE_COUNT_LIMIT: usize = 1024;
|
||||
type Files = Vec<(usize, PathBuf, Arc<str>)>;
|
||||
type FileResult = Result<Vec<Option<Change>>, String>;
|
||||
pub(crate) type ResultBatch = Vec<(usize, FileResult)>;
|
||||
type PickerRequest = (PathBuf, Vec<(PathBuf, Arc<str>)>);
|
||||
type Request = (u64, u64, Files, Option<PickerRequest>);
|
||||
|
||||
pub(crate) struct PickerChanges {
|
||||
pub(crate) root: PathBuf,
|
||||
pub(crate) result: Result<Vec<WorkingFileChange>, String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GutterStyle {
|
||||
#[default]
|
||||
Bar,
|
||||
Symbols,
|
||||
LineNumbers,
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum Change {
|
||||
Added,
|
||||
Removed,
|
||||
Modified,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
request: Option<Request>,
|
||||
result: Option<(u64, ResultBatch, Option<PickerChanges>)>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Shared {
|
||||
state: Mutex<State>,
|
||||
wake: Condvar,
|
||||
generation: AtomicU64,
|
||||
stopped: AtomicBool,
|
||||
}
|
||||
pub(crate) struct Worker {
|
||||
shared: Arc<Shared>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
impl Worker {
|
||||
pub(crate) fn start() -> io::Result<Self> {
|
||||
let shared = Arc::new(Shared::default());
|
||||
let worker = Arc::clone(&shared);
|
||||
let thread = thread::Builder::new()
|
||||
.name("vamp-gutter".into())
|
||||
.spawn(move || {
|
||||
let mut picker_reader = WorkingFileReader::default();
|
||||
loop {
|
||||
let Ok(mut state) = worker.state.lock() else {
|
||||
return;
|
||||
};
|
||||
while state.request.is_none() && !worker.stopped.load(Ordering::Acquire) {
|
||||
let Ok(next) = worker.wake.wait(state) else {
|
||||
return;
|
||||
};
|
||||
state = next;
|
||||
}
|
||||
if worker.stopped.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let (generation, id, files, picker) = state.request.take().unwrap();
|
||||
drop(state);
|
||||
let cancelled = || {
|
||||
worker.stopped.load(Ordering::Acquire)
|
||||
|| worker.generation.load(Ordering::Acquire) != generation
|
||||
};
|
||||
let mut results = Vec::with_capacity(files.len());
|
||||
for (buffer, path, contents) in files {
|
||||
if cancelled() {
|
||||
break;
|
||||
}
|
||||
results.push((buffer, read_file(&path, &contents, &cancelled)));
|
||||
}
|
||||
let picker = picker.map(|(root, buffers)| PickerChanges {
|
||||
result: picker_reader.read(&root, &buffers, &cancelled),
|
||||
root,
|
||||
});
|
||||
let Ok(mut state) = worker.state.lock() else {
|
||||
return;
|
||||
};
|
||||
if !cancelled() {
|
||||
state.result = Some((id, results, picker));
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Self {
|
||||
shared,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn request(
|
||||
&mut self,
|
||||
id: u64,
|
||||
files: Files,
|
||||
picker: Option<PickerRequest>,
|
||||
) -> io::Result<()> {
|
||||
let size = files.iter().try_fold(0usize, |size, (_, path, text)| {
|
||||
size.checked_add(text.len())?
|
||||
.checked_add(path.as_os_str().len())
|
||||
});
|
||||
let picker_size = picker.as_ref().map_or(Some(0), |(root, buffers)| {
|
||||
buffers
|
||||
.iter()
|
||||
.try_fold(root.as_os_str().len(), |size, (path, text)| {
|
||||
size.checked_add(text.len())?
|
||||
.checked_add(path.as_os_str().len())
|
||||
})
|
||||
});
|
||||
let count = files.len() + picker.as_ref().map_or(0, |(_, buffers)| buffers.len());
|
||||
if count > FILE_COUNT_LIMIT
|
||||
|| size
|
||||
.zip(picker_size)
|
||||
.and_then(|(files, picker)| files.checked_add(picker))
|
||||
.is_none_or(|size| size > BATCH_LIMIT)
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Gutter batch exceeds 64 MiB or 1024 files",
|
||||
));
|
||||
}
|
||||
let mut state = self
|
||||
.shared
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("Gutter worker stopped"))?;
|
||||
let generation = self
|
||||
.shared
|
||||
.generation
|
||||
.fetch_add(1, Ordering::AcqRel)
|
||||
.wrapping_add(1);
|
||||
state.request = Some((generation, id, files, picker));
|
||||
state.result = None;
|
||||
self.shared.wake.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn poll(&mut self) -> Option<(u64, ResultBatch, Option<PickerChanges>)> {
|
||||
self.shared.state.lock().ok()?.result.take()
|
||||
}
|
||||
}
|
||||
impl Drop for Worker {
|
||||
fn drop(&mut self) {
|
||||
let state = self.shared.state.lock();
|
||||
self.shared.stopped.store(true, Ordering::Release);
|
||||
self.shared.wake.notify_one();
|
||||
drop(state);
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn checked(command: Command, cancelled: &dyn Fn() -> bool) -> Result<Vec<u8>, String> {
|
||||
run(command, &[], cancelled)?.ok_or_else(|| "Cannot read gutter repository metadata".into())
|
||||
}
|
||||
|
||||
fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> FileResult {
|
||||
if contents.len() > FILE_LIMIT {
|
||||
return Err("Gutter file exceeds 16 MiB".into());
|
||||
}
|
||||
if contents.as_bytes().contains(&0) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) if !metadata.file_type().is_file() => return Ok(vec![]),
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
// Check the lexical path before canonicalizing: metadata files and symlinked parents
|
||||
// must not acquire the markers of their target outside the checkout.
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join(path)
|
||||
};
|
||||
if absolute
|
||||
.components()
|
||||
.any(|part| part.as_os_str() == ".git" || part.as_os_str() == ".jj")
|
||||
{
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let Some(root) = absolute.parent().and_then(|parent| {
|
||||
parent
|
||||
.ancestors()
|
||||
.find(|root| root.join(".jj").exists() || root.join(".git").exists())
|
||||
}) else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
if absolute
|
||||
.ancestors()
|
||||
.take_while(|part| *part != root)
|
||||
.any(|part| {
|
||||
fs::symlink_metadata(part).is_ok_and(|metadata| metadata.file_type().is_symlink())
|
||||
})
|
||||
{
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let relative = absolute.strip_prefix(root).map_err(|e| e.to_string())?;
|
||||
if safe_path(relative).is_err() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
// Resolve ancestors above the checkout, preserving a missing named-buffer tail.
|
||||
let root = fs::canonicalize(root).map_err(|e| e.to_string())?;
|
||||
let baseline = if root.join(".jj").exists() {
|
||||
jj_baseline(&root, relative, cancelled)?
|
||||
} else {
|
||||
git_baseline(&root, relative, cancelled)?
|
||||
};
|
||||
let Some(baseline) = baseline else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
if baseline.contains(&0) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let Ok(baseline) = std::str::from_utf8(&baseline) else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
if cancelled() {
|
||||
return Err("Gutter cancelled".into());
|
||||
}
|
||||
Ok(changes(baseline, contents))
|
||||
}
|
||||
|
||||
fn eligible(
|
||||
root: &Path,
|
||||
relative: &Path,
|
||||
store: Option<&Path>,
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
) -> Result<bool, String> {
|
||||
let command = |literal_pathspecs| {
|
||||
let mut command = if literal_pathspecs {
|
||||
git(root)
|
||||
} else {
|
||||
git_without_literal_pathspecs(root)
|
||||
};
|
||||
if let Some(store) = store {
|
||||
command
|
||||
.arg("--git-dir")
|
||||
.arg(store)
|
||||
.arg("--work-tree")
|
||||
.arg(root);
|
||||
}
|
||||
command
|
||||
};
|
||||
let mut tracked = command(true);
|
||||
tracked
|
||||
.args(["ls-files", "--cached", "-z", "--"])
|
||||
.arg(relative);
|
||||
let bytes = checked(tracked, cancelled)?;
|
||||
if bytes
|
||||
.split(|byte| *byte == 0)
|
||||
.any(|entry| entry == relative.as_os_str().as_encoded_bytes())
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
let mut ignored = command(false);
|
||||
ignored
|
||||
.args(["check-ignore", "--no-index", "-q", "--"])
|
||||
.arg(relative);
|
||||
match run_status(ignored, &[], cancelled)?.0 {
|
||||
Some(0) => Ok(false),
|
||||
Some(1) => Ok(true),
|
||||
_ => Err("Cannot read gutter ignore rules".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn git_baseline(
|
||||
root: &Path,
|
||||
relative: &Path,
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let mut head = git(root);
|
||||
head.args(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
|
||||
let (code, head) = run_status(head, &[], cancelled)?;
|
||||
if code == Some(1) {
|
||||
// An unborn repository still has an index and ignore rules.
|
||||
return Ok(eligible(root, relative, None, cancelled)?.then(Vec::new));
|
||||
}
|
||||
if code != Some(0) {
|
||||
return Err("Cannot resolve Git gutter HEAD".into());
|
||||
}
|
||||
let head = std::str::from_utf8(&head)
|
||||
.map_err(|_| "Invalid Git gutter HEAD")?
|
||||
.trim();
|
||||
let mut tree = git(root);
|
||||
tree.args(["ls-tree", "-z", "--full-tree", head, "--"])
|
||||
.arg(relative);
|
||||
let entries = checked(tree, cancelled)?;
|
||||
let Some(entry) = entries
|
||||
.split(|byte| *byte == 0)
|
||||
.find(|entry| !entry.is_empty())
|
||||
else {
|
||||
return Ok(eligible(root, relative, None, cancelled)?.then(Vec::new));
|
||||
};
|
||||
let tab = entry
|
||||
.iter()
|
||||
.position(|byte| *byte == b'\t')
|
||||
.ok_or("Invalid Git gutter tree")?;
|
||||
if &entry[tab + 1..] != relative.as_os_str().as_encoded_bytes() {
|
||||
return Err("Unexpected Git gutter path".into());
|
||||
}
|
||||
let fields = std::str::from_utf8(&entry[..tab]).map_err(|_| "Invalid Git gutter tree")?;
|
||||
let mut fields = fields.split(' ');
|
||||
if !matches!(fields.next(), Some("100644" | "100755")) || fields.next() != Some("blob") {
|
||||
return Ok(None);
|
||||
}
|
||||
let object = fields.next().ok_or("Missing Git gutter blob")?;
|
||||
let mut cat = git(root);
|
||||
cat.args(["cat-file", "blob", object]);
|
||||
checked(cat, cancelled).map(Some)
|
||||
}
|
||||
|
||||
fn jj_file_kind(
|
||||
root: &Path,
|
||||
revision: &str,
|
||||
pattern: &str,
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
) -> Result<Option<String>, String> {
|
||||
let mut command = jj_command(root);
|
||||
command.args([
|
||||
"file",
|
||||
"list",
|
||||
"-r",
|
||||
revision,
|
||||
"-T",
|
||||
"json(file_type) ++ \"\\n\"",
|
||||
"--",
|
||||
pattern,
|
||||
]);
|
||||
let bytes = checked(command, cancelled)?;
|
||||
if bytes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let kind =
|
||||
serde_json::from_slice(&bytes).map_err(|_| "Invalid Jujutsu gutter file metadata")?;
|
||||
Ok(Some(kind))
|
||||
}
|
||||
|
||||
fn jj_baseline(
|
||||
root: &Path,
|
||||
relative: &Path,
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let Ok(store) = store_for_jj(root) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(path) = relative.to_str() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut parents = jj_command(root);
|
||||
parents.args([
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-r",
|
||||
"@-",
|
||||
"-T",
|
||||
"commit_id ++ \"\\n\"",
|
||||
]);
|
||||
let bytes = checked(parents, cancelled)?;
|
||||
let ids = std::str::from_utf8(&bytes).map_err(|_| "Invalid Jujutsu gutter parents")?;
|
||||
let mut ids = ids.lines();
|
||||
let Some(parent) = ids.next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
// ponytail: no merged-parent materializer exists here; hide markers on merges
|
||||
// rather than report changes against an arbitrary first parent.
|
||||
if ids.next().is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
let pattern = jj_file_pattern(path);
|
||||
let baseline_kind = if parent.bytes().all(|byte| byte == b'0') {
|
||||
None
|
||||
} else {
|
||||
jj_file_kind(root, parent, &pattern, cancelled)?
|
||||
};
|
||||
if let Some(kind) = baseline_kind {
|
||||
if !matches!(kind.as_str(), "file" | "conflict") {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut show = jj_command(root);
|
||||
show.args(["file", "show", "-r", parent, "-T", "", "--", &pattern]);
|
||||
return checked(show, cancelled).map(Some);
|
||||
}
|
||||
if let Some(kind) = jj_file_kind(root, "@", &pattern, cancelled)? {
|
||||
return Ok(matches!(kind.as_str(), "file" | "conflict").then(Vec::new));
|
||||
}
|
||||
Ok(eligible(root, relative, Some(&store), cancelled)?.then(Vec::new))
|
||||
}
|
||||
|
||||
fn changes(before: &str, after: &str) -> Vec<Option<Change>> {
|
||||
fn source(text: &str) -> (Vec<String>, bool) {
|
||||
(
|
||||
text.split_terminator('\n').map(str::to_owned).collect(),
|
||||
text.ends_with('\n'),
|
||||
)
|
||||
}
|
||||
let before = normalize_newlines(before);
|
||||
let after = normalize_newlines(after);
|
||||
if before == after {
|
||||
return vec![None; after.split('\n').count()];
|
||||
}
|
||||
let (before, before_eof) = source(&before);
|
||||
let (after_lines, after_eof) = source(&after);
|
||||
let mut markers = vec![None; after.split('\n').count()];
|
||||
for hunk in line_hunks(&before, before_eof, &after_lines, after_eof) {
|
||||
if hunk.after_len == 0 {
|
||||
let anchor = hunk.after_start.min(markers.len() - 1);
|
||||
// A deletion sharing a row with a replacement remains modified.
|
||||
if markers[anchor].is_none() {
|
||||
markers[anchor] = Some(Change::Removed);
|
||||
}
|
||||
} else {
|
||||
for offset in 0..hunk.after_len {
|
||||
markers[hunk.after_start + offset] = Some(if offset < hunk.before_len {
|
||||
Change::Modified
|
||||
} else {
|
||||
Change::Added
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
markers
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsString;
|
||||
|
||||
static NEXT_TEST: AtomicU64 = AtomicU64::new(0);
|
||||
struct Temporary(PathBuf);
|
||||
impl Temporary {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"vamp-gutter-test-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEST.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
impl Drop for Temporary {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
fn command(root: &Path, program: &str, args: &[&str]) -> String {
|
||||
let mut command = Command::new(program);
|
||||
if program == "jj" {
|
||||
command.args([
|
||||
"--config=user.name=\"Gutter Author\"",
|
||||
"--config=user.email=\"gutter@example.test\"",
|
||||
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
|
||||
]);
|
||||
}
|
||||
let output = command
|
||||
.args(args)
|
||||
.current_dir(root)
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
||||
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
|
||||
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
|
||||
.env("JJ_CONFIG", "/dev/null")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap().trim().into()
|
||||
}
|
||||
fn git_fixture() -> (Temporary, PathBuf) {
|
||||
let temp = Temporary::new();
|
||||
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||
command(&temp.0, "git", &["config", "user.name", "Gutter Author"]);
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["config", "user.email", "gutter@example.test"],
|
||||
);
|
||||
let path = temp.0.join("-literal [file]\n\t\".txt");
|
||||
fs::write(&path, "first\nsecond\nthird\n").unwrap();
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["add", "--", path.file_name().unwrap().to_str().unwrap()],
|
||||
);
|
||||
command(&temp.0, "git", &["commit", "--quiet", "-m", "Baseline"]);
|
||||
(temp, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_replacements_surplus_deletions_empty_and_eof_rows() {
|
||||
use Change::*;
|
||||
for (before, after, expected) in [
|
||||
("first\nsecond\n", "first\nsecond\n", vec![None, None, None]),
|
||||
(
|
||||
"first\r\nsecond\r\n",
|
||||
"first\nsecond\n",
|
||||
vec![None, None, None],
|
||||
),
|
||||
("first\rsecond\r", "first\nsecond\n", vec![None, None, None]),
|
||||
(
|
||||
"first\r\nsecond\r\nthird\r\n",
|
||||
"first\nchanged\nthird\n",
|
||||
vec![None, Some(Modified), None, None],
|
||||
),
|
||||
(
|
||||
"first\nsecond\nthird\n",
|
||||
"first\rchanged\rthird\r",
|
||||
vec![None, Some(Modified), None, None],
|
||||
),
|
||||
("first\n", "first\nnew\n", vec![None, Some(Added), None]),
|
||||
(
|
||||
"old\n",
|
||||
"changed\nsurplus\n",
|
||||
vec![Some(Modified), Some(Added), None],
|
||||
),
|
||||
(
|
||||
"old\nextra\nlast\n",
|
||||
"changed\nlast\n",
|
||||
vec![Some(Modified), None, None],
|
||||
),
|
||||
(
|
||||
"first\nremoved\nlast\n",
|
||||
"first\nlast\n",
|
||||
vec![None, Some(Removed), None],
|
||||
),
|
||||
("first\nremoved\n", "first\n", vec![None, Some(Removed)]),
|
||||
("first\nremoved", "first", vec![Some(Modified)]),
|
||||
("first\nremoved", "first\n", vec![None, Some(Removed)]),
|
||||
("removed\n", "", vec![Some(Removed)]),
|
||||
("", "new\n", vec![Some(Added), None]),
|
||||
("", "", vec![None]),
|
||||
("same", "same\n", vec![Some(Modified), None]),
|
||||
] {
|
||||
assert_eq!(changes(before, after), expected, "{before:?} -> {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_crlf_baseline_matches_normalized_editor_rows() {
|
||||
let (temp, _) = git_fixture();
|
||||
let path = temp.0.join("crlf");
|
||||
fs::write(&path, "first\r\nsecond\r\nthird\r\n").unwrap();
|
||||
command(&temp.0, "git", &["add", "--", "crlf"]);
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["commit", "--quiet", "-m", "CRLF baseline"],
|
||||
);
|
||||
assert_eq!(
|
||||
read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(),
|
||||
vec![None, None, None, None]
|
||||
);
|
||||
assert_eq!(
|
||||
read_file(&path, "first\nchanged\nthird\n", &|| false).unwrap(),
|
||||
vec![None, Some(Change::Modified), None, None]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_head_includes_staged_disk_and_unsaved_edits_without_writes() {
|
||||
let (temp, path) = git_fixture();
|
||||
fs::write(&path, "staged\nsecond\nthird\n").unwrap();
|
||||
command(
|
||||
&temp.0,
|
||||
"git",
|
||||
&["add", "--", path.file_name().unwrap().to_str().unwrap()],
|
||||
);
|
||||
fs::write(&path, "staged\ndisk\nthird\n").unwrap();
|
||||
let index = fs::read(temp.0.join(".git/index")).unwrap();
|
||||
let head = command(&temp.0, "git", &["rev-parse", "HEAD"]);
|
||||
assert_eq!(
|
||||
read_file(&path, "staged\ndisk\nunsaved\n", &|| false).unwrap(),
|
||||
vec![
|
||||
Some(Change::Modified),
|
||||
Some(Change::Modified),
|
||||
Some(Change::Modified),
|
||||
None
|
||||
]
|
||||
);
|
||||
assert_eq!(fs::read(temp.0.join(".git/index")).unwrap(), index);
|
||||
assert_eq!(command(&temp.0, "git", &["rev-parse", "HEAD"]), head);
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "staged\ndisk\nthird\n");
|
||||
fs::remove_file(&path).unwrap();
|
||||
assert_eq!(
|
||||
read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(),
|
||||
vec![None, None, None, None]
|
||||
);
|
||||
assert_eq!(
|
||||
read_file(&temp.0.join("new/named"), "unsaved\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Added), None]
|
||||
);
|
||||
let untracked = temp.0.join("untracked");
|
||||
fs::write(&untracked, "unsaved\n").unwrap();
|
||||
assert_eq!(
|
||||
read_file(&untracked, "unsaved\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Added), None]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unborn_untracked_missing_ignored_metadata_binary_and_symlink_paths() {
|
||||
let temp = Temporary::new();
|
||||
let path = temp.0.join("new/named");
|
||||
assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty());
|
||||
command(&temp.0, "git", &["init", "--quiet", "--template="]);
|
||||
assert_eq!(
|
||||
read_file(&path, "unsaved\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Added), None]
|
||||
);
|
||||
let disk = temp.0.join("untracked");
|
||||
fs::write(&disk, "disk\n").unwrap();
|
||||
assert_eq!(
|
||||
read_file(&disk, "unsaved\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Added), None]
|
||||
);
|
||||
fs::write(temp.0.join(".gitignore"), "new/\nuntracked\n").unwrap();
|
||||
assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty());
|
||||
assert!(read_file(&disk, "unsaved\n", &|| false).unwrap().is_empty());
|
||||
assert!(
|
||||
read_file(&temp.0.join(".git/config"), "text\n", &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
read_file(&disk, "binary\0text", &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
std::os::unix::fs::symlink(&disk, temp.0.join("link")).unwrap();
|
||||
assert!(
|
||||
read_file(&temp.0.join("link"), "text\n", &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
std::os::unix::fs::symlink(&temp.0, temp.0.join("linked")).unwrap();
|
||||
assert!(
|
||||
read_file(&temp.0.join("linked/untracked"), "text\n", &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_non_utf8_filename_is_literal() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let (temp, _) = git_fixture();
|
||||
let name = OsString::from_vec(b"non-utf8-\xff".to_vec());
|
||||
let path = temp.0.join(&name);
|
||||
match fs::write(&path, "old\n") {
|
||||
Ok(()) => {}
|
||||
// APFS rejects non-UTF-8 names before repository code can read them.
|
||||
Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => return,
|
||||
Err(error) => panic!("Cannot create non-UTF-8 fixture: {error}"),
|
||||
}
|
||||
let mut add = git(&temp.0);
|
||||
add.args(["add", "--"]).arg(&name);
|
||||
checked(add, &|| false).unwrap();
|
||||
command(&temp.0, "git", &["commit", "--quiet", "-m", "Byte path"]);
|
||||
assert_eq!(
|
||||
read_file(&path, "changed\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Modified), None]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jj_parent_root_colocation_ignore_and_merge_queries_are_read_only() {
|
||||
for colocate in ["--colocate", "--no-colocate"] {
|
||||
let temp = Temporary::new();
|
||||
command(&temp.0, "jj", &["git", "init", colocate, "."]);
|
||||
let name = "-literal [file]\n\t\".txt";
|
||||
let path = temp.0.join(name);
|
||||
fs::write(&path, "original\n").unwrap();
|
||||
assert_eq!(
|
||||
read_file(&path, "unsaved\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Added), None]
|
||||
);
|
||||
fs::write(temp.0.join(".gitignore"), "ignored\n").unwrap();
|
||||
assert!(
|
||||
read_file(&temp.0.join("ignored"), "text\n", &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
command(&temp.0, "jj", &["describe", "-m", "Baseline"]);
|
||||
command(&temp.0, "jj", &["new"]);
|
||||
let operation = command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id",
|
||||
],
|
||||
);
|
||||
let store = store_for_jj(&temp.0).unwrap();
|
||||
let refs = fs::read(store.join("HEAD")).unwrap();
|
||||
fs::write(&path, "disk edit\n").unwrap();
|
||||
assert_eq!(
|
||||
read_file(&path, "inserted\noriginal\n", &|| false).unwrap(),
|
||||
vec![Some(Change::Added), None, None]
|
||||
);
|
||||
assert_eq!(fs::read(store.join("HEAD")).unwrap(), refs);
|
||||
assert_eq!(
|
||||
command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"op",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-n",
|
||||
"1",
|
||||
"-T",
|
||||
"id",
|
||||
]
|
||||
),
|
||||
operation
|
||||
);
|
||||
let parent = command(
|
||||
&temp.0,
|
||||
"jj",
|
||||
&[
|
||||
"--ignore-working-copy",
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-r",
|
||||
"@-",
|
||||
"-T",
|
||||
"commit_id",
|
||||
],
|
||||
);
|
||||
command(&temp.0, "jj", &["new", "root()"]);
|
||||
fs::write(temp.0.join("sibling"), "sibling\n").unwrap();
|
||||
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
|
||||
command(&temp.0, "jj", &["new", &parent, "@"]);
|
||||
assert!(
|
||||
read_file(&path, "original\n", &|| false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_retains_only_latest_batch_and_file_errors_keep_good_siblings() {
|
||||
let (_temp, path) = git_fixture();
|
||||
let mut worker = Worker::start().unwrap();
|
||||
let oversized: Arc<str> = "x".repeat(FILE_LIMIT + 1).into();
|
||||
for id in 0..20 {
|
||||
worker
|
||||
.request(
|
||||
id,
|
||||
vec![
|
||||
(5, path.clone(), "first\nchanged\nthird\n".into()),
|
||||
(8, path.clone(), Arc::clone(&oversized)),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
loop {
|
||||
if let Some((id, result, picker)) = worker.poll() {
|
||||
assert!(picker.is_none());
|
||||
assert_eq!(id, 19);
|
||||
assert_eq!(result[0].0, 5);
|
||||
assert_eq!(
|
||||
result[0].1.as_ref().unwrap(),
|
||||
&vec![None, Some(Change::Modified), None, None]
|
||||
);
|
||||
assert_eq!(result[1].0, 8);
|
||||
assert_eq!(
|
||||
result[1].1.as_ref().unwrap_err(),
|
||||
"Gutter file exceeds 16 MiB"
|
||||
);
|
||||
break;
|
||||
}
|
||||
thread::yield_now();
|
||||
}
|
||||
assert_eq!(
|
||||
worker
|
||||
.request(
|
||||
20,
|
||||
vec![(0, path, "x".repeat(BATCH_LIMIT + 1).into())],
|
||||
None
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::InvalidInput
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_status_is_optional_and_errors_do_not_discard_line_results() {
|
||||
let (temp, path) = git_fixture();
|
||||
let mut worker = Worker::start().unwrap();
|
||||
worker
|
||||
.request(
|
||||
1,
|
||||
vec![],
|
||||
Some((
|
||||
temp.0.clone(),
|
||||
vec![(path.clone(), "dirty buffer\n".into())],
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
loop {
|
||||
if let Some((id, lines, picker)) = worker.poll() {
|
||||
assert_eq!(id, 1);
|
||||
assert!(lines.is_empty());
|
||||
let picker = picker.unwrap();
|
||||
assert_eq!(picker.root, temp.0);
|
||||
let changes = picker.result.unwrap();
|
||||
assert_eq!(changes.len(), 1);
|
||||
assert_eq!(changes[0].path, path.strip_prefix(&temp.0).unwrap());
|
||||
assert_eq!(changes[0].status, crate::diff::FileStatus::Modified);
|
||||
break;
|
||||
}
|
||||
thread::yield_now();
|
||||
}
|
||||
worker
|
||||
.request(
|
||||
2,
|
||||
vec![(4, path.clone(), "first\nchanged\nthird\n".into())],
|
||||
Some((
|
||||
temp.0.clone(),
|
||||
vec![(path, "x".repeat(FILE_LIMIT + 1).into())],
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
loop {
|
||||
if let Some((id, lines, picker)) = worker.poll() {
|
||||
assert_eq!(id, 2);
|
||||
assert_eq!(lines[0].0, 4);
|
||||
assert!(lines[0].1.is_ok());
|
||||
assert!(picker.unwrap().result.unwrap_err().contains("16 MiB"));
|
||||
break;
|
||||
}
|
||||
thread::yield_now();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod clipboard;
|
||||
pub mod diff;
|
||||
pub mod editor;
|
||||
pub mod files;
|
||||
pub mod gutter;
|
||||
pub mod lsp;
|
||||
mod lsp_config;
|
||||
mod markdown;
|
||||
|
||||
@@ -30,12 +30,15 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
|
||||
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
|
||||
u undo, U redo, : command, / search, n/N next/previous match
|
||||
yy copies whole lines; dd deletes them; cc changes them and enters Insert; counts: 3yy, 2dd, 2cc
|
||||
dw/db cut forward/backward words on the current line; cw/cb cut the same spans and enter Insert
|
||||
Word counts before/after the operator multiply: d2w, 2cw, 2c3b; newlines are preserved
|
||||
Linewise p/P paste below/above the current row; characterwise p/P paste after/before the grapheme
|
||||
Normal/Visual: . repeats last native edit at current targets (one undo group)
|
||||
v characterwise select, V linewise select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below
|
||||
y/d/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it
|
||||
Esc keeps only the primary caret
|
||||
Visual: motions extend selections, y copy, d/x/Delete cut, c change
|
||||
r<char> replaces every selected grapheme, preserving newlines; one undo action
|
||||
I/A insert at each selected line's first nonblank/end
|
||||
Ctrl+l split rows, Ctrl+n add occurrence, Ctrl+s skip, Ctrl+a all
|
||||
v/V switches selection shape; same selector or Esc cancels retaining carets
|
||||
|
||||
+1340
-41
File diff suppressed because it is too large
Load Diff
+312
-3
@@ -39,10 +39,15 @@ pub struct Config {
|
||||
pub file_explorer_view: String,
|
||||
pub clipboard_mode: String,
|
||||
pub git_blame: bool,
|
||||
pub git_gutter: crate::gutter::GutterStyle,
|
||||
pub top_bar: ChromeLine,
|
||||
pub bottom_bar: ChromeLine,
|
||||
pub popup: PopupConfig,
|
||||
pub labels: BTreeMap<String, String>,
|
||||
pub scroll_distance: Option<usize>,
|
||||
pub highlight_trailing_whitespace: bool,
|
||||
pub whitespace_characters: BTreeMap<char, char>,
|
||||
pub whitespace_scope: crate::editor::WhitespaceScope,
|
||||
pub diff_context_lines: usize,
|
||||
pub diff_expand_lines: usize,
|
||||
pub diff_view_mode: crate::diff::ViewMode,
|
||||
@@ -83,10 +88,15 @@ impl Default for Config {
|
||||
file_explorer_view: "tree".into(),
|
||||
clipboard_mode: "system".into(),
|
||||
git_blame: true,
|
||||
git_gutter: crate::gutter::GutterStyle::default(),
|
||||
top_bar: ChromeLine::top(),
|
||||
bottom_bar: ChromeLine::bottom(),
|
||||
popup: PopupConfig::default(),
|
||||
labels: BTreeMap::new(),
|
||||
scroll_distance: Some(0),
|
||||
highlight_trailing_whitespace: false,
|
||||
whitespace_characters: BTreeMap::new(),
|
||||
whitespace_scope: crate::editor::WhitespaceScope::All,
|
||||
diff_context_lines: 10,
|
||||
diff_expand_lines: 15,
|
||||
diff_view_mode: crate::diff::ViewMode::Split,
|
||||
@@ -114,10 +124,15 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
file_explorer_view,
|
||||
clipboard_mode,
|
||||
git_blame,
|
||||
git_gutter,
|
||||
top_bar,
|
||||
bottom_bar,
|
||||
popup,
|
||||
labels,
|
||||
scroll_distance,
|
||||
highlight_trailing_whitespace,
|
||||
whitespace_characters,
|
||||
whitespace_scope,
|
||||
diff_context_lines,
|
||||
diff_expand_lines,
|
||||
diff_view_mode,
|
||||
@@ -146,10 +161,15 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
config.file_explorer_view = file_explorer_view;
|
||||
config.clipboard_mode = clipboard_mode;
|
||||
config.git_blame = git_blame;
|
||||
config.git_gutter = git_gutter;
|
||||
config.top_bar = top_bar;
|
||||
config.bottom_bar = bottom_bar;
|
||||
config.popup = popup;
|
||||
config.labels = labels;
|
||||
config.scroll_distance = scroll_distance;
|
||||
config.highlight_trailing_whitespace = highlight_trailing_whitespace;
|
||||
config.whitespace_characters = whitespace_characters;
|
||||
config.whitespace_scope = whitespace_scope;
|
||||
config.diff_context_lines = diff_context_lines;
|
||||
config.diff_expand_lines = diff_expand_lines;
|
||||
config.diff_view_mode = diff_view_mode;
|
||||
@@ -446,6 +466,17 @@ fn diff_expand_lines<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Resul
|
||||
diff_line_count(deserializer, "diff_expand_lines", 1)
|
||||
}
|
||||
|
||||
fn scroll_distance<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<usize>, D::Error> {
|
||||
Option::<usize>::deserialize(deserializer).map_err(|error| {
|
||||
serde::de::Error::custom(format!(
|
||||
"scroll_distance must be nil or an Integer between 0 and {}: {error}",
|
||||
usize::MAX
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
// Startup-only configuration does not inflate every queued plugin response.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -462,6 +493,7 @@ struct ConfigFrame {
|
||||
file_explorer_view: String,
|
||||
clipboard_mode: String,
|
||||
git_blame: bool,
|
||||
git_gutter: crate::gutter::GutterStyle,
|
||||
#[serde(deserialize_with = "crate::chrome::top")]
|
||||
top_bar: ChromeLine,
|
||||
#[serde(deserialize_with = "crate::chrome::bottom")]
|
||||
@@ -469,6 +501,12 @@ struct ConfigFrame {
|
||||
popup: PopupConfig,
|
||||
#[serde(deserialize_with = "crate::chrome::labels")]
|
||||
labels: BTreeMap<String, String>,
|
||||
#[serde(deserialize_with = "scroll_distance")]
|
||||
scroll_distance: Option<usize>,
|
||||
highlight_trailing_whitespace: bool,
|
||||
#[serde(deserialize_with = "whitespace_characters")]
|
||||
whitespace_characters: BTreeMap<char, char>,
|
||||
whitespace_scope: crate::editor::WhitespaceScope,
|
||||
#[serde(deserialize_with = "diff_context_lines")]
|
||||
diff_context_lines: usize,
|
||||
#[serde(deserialize_with = "diff_expand_lines")]
|
||||
@@ -478,6 +516,30 @@ struct ConfigFrame {
|
||||
diff_follow_working_copy: bool,
|
||||
}
|
||||
|
||||
fn whitespace_characters<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<BTreeMap<char, char>, D::Error> {
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
let characters = BTreeMap::<char, char>::deserialize(deserializer)?;
|
||||
for (&whitespace, &marker) in &characters {
|
||||
if !whitespace.is_whitespace()
|
||||
|| (!matches!(whitespace, '\t' | '\n')
|
||||
&& (whitespace.is_control() || whitespace.width().unwrap_or(0) == 0))
|
||||
{
|
||||
return Err(serde::de::Error::custom(
|
||||
"whitespace_characters keys must be visible-width whitespace characters, tabs, or newlines",
|
||||
));
|
||||
}
|
||||
if marker.is_control() || marker.width() != Some(1) {
|
||||
return Err(serde::de::Error::custom(
|
||||
"whitespace_characters values must be printable single-cell characters",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(characters)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type", deny_unknown_fields)]
|
||||
enum Response {
|
||||
@@ -1212,14 +1274,41 @@ end
|
||||
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
||||
}
|
||||
for frame in [
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"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}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
|
||||
] {
|
||||
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_gutter_config_modes_default_and_rejection() {
|
||||
use crate::gutter::GutterStyle;
|
||||
let fixture = Fixture::new();
|
||||
let path = fixture.file("config.rb", "Vamp.configure { |_config| }");
|
||||
assert_eq!(load_config(&path).unwrap().git_gutter, GutterStyle::Bar);
|
||||
for (name, expected) in [
|
||||
("bar", GutterStyle::Bar),
|
||||
("symbols", GutterStyle::Symbols),
|
||||
("line_numbers", GutterStyle::LineNumbers),
|
||||
("none", GutterStyle::None),
|
||||
] {
|
||||
let path = fixture.file(
|
||||
"config.rb",
|
||||
&format!("Vamp.configure {{ |config| config.git_gutter = {name:?} }}"),
|
||||
);
|
||||
assert_eq!(load_config(&path).unwrap().git_gutter, expected);
|
||||
}
|
||||
for value in ["\"unsupported\"", "\"\"", "false", "7", ":bar"] {
|
||||
let path = fixture.file(
|
||||
"config.rb",
|
||||
&format!("Vamp.configure {{ |config| config.git_gutter = {value} }}"),
|
||||
);
|
||||
assert!(load_config(&path).is_err(), "{value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_blame_boolean_config_typed_toggle_palette_and_wire_validation() {
|
||||
let fixture = Fixture::new();
|
||||
@@ -1262,6 +1351,11 @@ end
|
||||
"type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [],
|
||||
"leader": "space", "file_explorer_side": "right", "file_picker_view": "tree",
|
||||
"file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true,
|
||||
"git_gutter": "bar",
|
||||
"scroll_distance": 0,
|
||||
"highlight_trailing_whitespace": false,
|
||||
"whitespace_characters": {},
|
||||
"whitespace_scope": "all",
|
||||
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
|
||||
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
|
||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||
@@ -1280,6 +1374,151 @@ end
|
||||
assert!(serde_json::from_value::<Response>(frame).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_whitespace_configuration_rejects_non_booleans() {
|
||||
let fixture = Fixture::new();
|
||||
for value in ["nil", "0", "\"true\"", "[]", "{}"] {
|
||||
let path = fixture.file(
|
||||
"config.rb",
|
||||
&format!("Vamp.configure {{ |c| c.highlight_trailing_whitespace = {value} }}"),
|
||||
);
|
||||
assert!(
|
||||
load_config(&path)
|
||||
.unwrap_err()
|
||||
.contains("highlight_trailing_whitespace must be true or false")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_characters_validate_real_ruby_configuration() {
|
||||
let fixture = Fixture::new();
|
||||
for value in [
|
||||
"nil",
|
||||
"false",
|
||||
"[]",
|
||||
r#"{ space: "·" }"#,
|
||||
r#"{ " " => 1 }"#,
|
||||
r#"{ "" => "." }"#,
|
||||
r#"{ " " => "." }"#,
|
||||
r#"{ " " => ".." }"#,
|
||||
r#"{ "x" => "." }"#,
|
||||
r#"{ "\u000b" => "." }"#,
|
||||
r#"{ "\r" => "." }"#,
|
||||
r#"{ "\u000c" => "." }"#,
|
||||
r#"{ "\u0085" => "." }"#,
|
||||
r#"{ "\u200b" => "." }"#,
|
||||
r#"{ " " => "\t" }"#,
|
||||
r#"{ " " => "\n" }"#,
|
||||
r#"{ " " => "\u0301" }"#,
|
||||
r#"{ " " => "界" }"#,
|
||||
] {
|
||||
let path = fixture.file(
|
||||
"config.rb",
|
||||
&format!("Vamp.configure {{ |c| c.whitespace_characters = {value} }}"),
|
||||
);
|
||||
assert!(load_config(&path).is_err(), "{value}");
|
||||
}
|
||||
let path = fixture.file(
|
||||
"config.rb",
|
||||
r#"Vamp.configure { |c| c.whitespace_characters = { " " => "·", "\t" => "→", "\n" => "↵", "\u00a0" => "⍽", "\u3000" => "_" } }"#,
|
||||
);
|
||||
assert_eq!(
|
||||
load_config(&path).unwrap().whitespace_characters,
|
||||
BTreeMap::from([
|
||||
(' ', '·'),
|
||||
('\t', '→'),
|
||||
('\n', '↵'),
|
||||
('\u{a0}', '⍽'),
|
||||
('\u{3000}', '_'),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_scope_validates_ruby_atomically_and_requires_valid_wire_values() {
|
||||
use crate::editor::WhitespaceScope;
|
||||
|
||||
let fixture = Fixture::new();
|
||||
let path = fixture.file("config.rb", "Vamp.configure {}");
|
||||
assert_eq!(
|
||||
load_config(&path).unwrap().whitespace_scope,
|
||||
WhitespaceScope::All
|
||||
);
|
||||
for (value, expected) in [
|
||||
("all", WhitespaceScope::All),
|
||||
("leading", WhitespaceScope::Leading),
|
||||
] {
|
||||
let path = fixture.file(
|
||||
"config.rb",
|
||||
&format!(r#"Vamp.configure {{ |c| c.whitespace_scope = "{value}" }}"#),
|
||||
);
|
||||
assert_eq!(load_config(&path).unwrap().whitespace_scope, expected);
|
||||
}
|
||||
for value in [
|
||||
"nil",
|
||||
"false",
|
||||
"0",
|
||||
"[]",
|
||||
"{}",
|
||||
":all",
|
||||
r#""""#,
|
||||
r#""unknown""#,
|
||||
] {
|
||||
let path = fixture.file(
|
||||
"config.rb",
|
||||
&format!("Vamp.configure {{ |c| c.whitespace_scope = {value} }}"),
|
||||
);
|
||||
assert!(load_config(&path).is_err(), "{value}");
|
||||
let path = fixture.file(
|
||||
"config.rb",
|
||||
&format!(
|
||||
r#"Vamp.configure do |c|
|
||||
c.whitespace_scope = "leading"
|
||||
begin
|
||||
c.whitespace_scope = {value}
|
||||
raise "invalid scope accepted"
|
||||
rescue TypeError, ArgumentError
|
||||
end
|
||||
end"#,
|
||||
),
|
||||
);
|
||||
assert_eq!(
|
||||
load_config(&path).unwrap().whitespace_scope,
|
||||
WhitespaceScope::Leading
|
||||
);
|
||||
}
|
||||
let mut frame = serde_json::json!({
|
||||
"type": "config", "version": 1, "plugins": [], "lsp": {}, "bindings": [],
|
||||
"leader": "space", "file_explorer_side": "right", "file_picker_view": "tree",
|
||||
"file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true,
|
||||
"git_gutter": "bar", "scroll_distance": 0, "highlight_trailing_whitespace": false,
|
||||
"whitespace_characters": {}, "whitespace_scope": "all",
|
||||
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
|
||||
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
|
||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||
"diff_follow_working_copy": true
|
||||
});
|
||||
for value in ["all", "leading"] {
|
||||
frame["whitespace_scope"] = serde_json::json!(value);
|
||||
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
|
||||
}
|
||||
for value in [
|
||||
serde_json::Value::Null,
|
||||
serde_json::json!(false),
|
||||
serde_json::json!(0),
|
||||
serde_json::json!([]),
|
||||
serde_json::json!({}),
|
||||
serde_json::json!(""),
|
||||
serde_json::json!("unknown"),
|
||||
] {
|
||||
frame["whitespace_scope"] = value;
|
||||
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
|
||||
}
|
||||
frame.as_object_mut().unwrap().remove("whitespace_scope");
|
||||
assert!(serde_json::from_value::<Response>(frame).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_diff_tracking_rejects_non_boolean_configuration() {
|
||||
let fixture = Fixture::new();
|
||||
@@ -1512,6 +1751,10 @@ end
|
||||
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||||
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
||||
"scroll_distance": 0,
|
||||
"highlight_trailing_whitespace": false,
|
||||
"whitespace_characters": {},
|
||||
"whitespace_scope": "all",
|
||||
"popup": {"command": {}, "search": {}},
|
||||
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7,
|
||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||
@@ -1552,6 +1795,67 @@ end
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_scroll_distance_accepts_nil_and_counts_and_rejects_invalid_values() {
|
||||
let fixture = Fixture::new();
|
||||
let frame = serde_json::json!({
|
||||
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
||||
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||||
"clipboard_mode": "system", "git_blame": true, "git_gutter": "bar",
|
||||
"highlight_trailing_whitespace": false,
|
||||
"whitespace_characters": {},
|
||||
"whitespace_scope": "all",
|
||||
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
||||
"popup": {"command": {}, "search": {}},
|
||||
"diff_context_lines": 10, "diff_expand_lines": 15,
|
||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||
"diff_follow_working_copy": true, "scroll_distance": 0
|
||||
});
|
||||
for distance in [None, Some(0), Some(7), Some(usize::MAX)] {
|
||||
let value = distance.map_or_else(|| "nil".into(), |value| value.to_string());
|
||||
let path = fixture.file(
|
||||
"config.rb",
|
||||
&format!("Vamp.configure {{ |config| config.scroll_distance = {value} }}"),
|
||||
);
|
||||
assert_eq!(load_config(&path).unwrap().scroll_distance, distance);
|
||||
let mut valid_frame = frame.clone();
|
||||
valid_frame["scroll_distance"] = serde_json::json!(distance);
|
||||
let response = serde_json::from_value::<Response>(valid_frame).unwrap();
|
||||
let Response::Config(config) = response else {
|
||||
panic!("expected a configuration frame");
|
||||
};
|
||||
assert_eq!(config.scroll_distance, distance);
|
||||
}
|
||||
for value in [
|
||||
"-1",
|
||||
"1.5",
|
||||
"\"3\"",
|
||||
"true",
|
||||
"false",
|
||||
"18446744073709551616",
|
||||
] {
|
||||
let path = fixture.file(
|
||||
"config.rb",
|
||||
&format!("Vamp.configure {{ |config| config.scroll_distance = {value} }}"),
|
||||
);
|
||||
let error = load_config(&path).unwrap_err();
|
||||
assert!(
|
||||
error.contains("scroll_distance") && error.contains("must be"),
|
||||
"{value}: {error}"
|
||||
);
|
||||
let mut invalid_frame = frame.clone();
|
||||
invalid_frame["scroll_distance"] = serde_json::from_str(value).unwrap();
|
||||
let error = serde_json::from_value::<Response>(invalid_frame)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
error.to_string().contains("scroll_distance"),
|
||||
"{value}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_configuration_is_disabled_unless_explicitly_enabled() {
|
||||
let fixture = Fixture::new();
|
||||
@@ -1928,6 +2232,11 @@ end"#,
|
||||
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||||
"git_blame": true,
|
||||
"git_gutter": "bar",
|
||||
"scroll_distance": 0,
|
||||
"highlight_trailing_whitespace": false,
|
||||
"whitespace_characters": {},
|
||||
"whitespace_scope": "all",
|
||||
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
||||
"popup": {"command": {}, "search": {}},
|
||||
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
|
||||
|
||||
@@ -137,9 +137,14 @@ module Vamp
|
||||
@file_explorer_view = "tree"
|
||||
@clipboard_mode = "system"
|
||||
@git_blame = true
|
||||
@git_gutter = "bar"
|
||||
@bar = BarConfig.new
|
||||
@popup = PopupConfig.new
|
||||
@labels = {}
|
||||
@scroll_distance = 0
|
||||
@highlight_trailing_whitespace = false
|
||||
@whitespace_characters = {}
|
||||
@whitespace_scope = "all"
|
||||
@diff_context_lines = 10
|
||||
@diff_expand_lines = 15
|
||||
@diff_view_mode = "split"
|
||||
@@ -197,6 +202,14 @@ module Vamp
|
||||
@git_blame = enabled
|
||||
end
|
||||
|
||||
# @param style [String] "bar", "symbols", "line_numbers", or "none"
|
||||
def git_gutter=(style)
|
||||
style = nonempty_string!(style, "git gutter")
|
||||
raise ArgumentError, "git gutter must be bar, symbols, line_numbers, or none" unless %w[bar symbols line_numbers none].include?(style)
|
||||
|
||||
@git_gutter = style
|
||||
end
|
||||
|
||||
# @param values [Hash{String, Symbol => String}]
|
||||
# @return [Hash]
|
||||
def labels=(values)
|
||||
@@ -205,6 +218,42 @@ module Vamp
|
||||
end
|
||||
end
|
||||
|
||||
# @param distance [Integer, nil] nonnegative cursor margin, or nil to center
|
||||
# @return [Integer, nil]
|
||||
def scroll_distance=(distance)
|
||||
@scroll_distance = distance.nil? ? nil : diff_line_count!(distance, "scroll_distance", 0)
|
||||
end
|
||||
|
||||
# @param enabled [Boolean]
|
||||
# @return [Boolean]
|
||||
def highlight_trailing_whitespace=(enabled)
|
||||
raise TypeError, "highlight_trailing_whitespace must be true or false" unless [true, false].include?(enabled)
|
||||
|
||||
@highlight_trailing_whitespace = enabled
|
||||
end
|
||||
|
||||
# @param characters [Hash{String => String}] whitespace character to single-cell marker
|
||||
# @return [Hash{String => String}]
|
||||
def whitespace_characters=(characters)
|
||||
raise TypeError, "whitespace_characters must be a Hash" unless characters.is_a?(Hash)
|
||||
|
||||
characters.each do |whitespace, marker|
|
||||
unless [whitespace, marker].all? { |value| value.is_a?(String) && value.length == 1 }
|
||||
raise TypeError, "whitespace_characters keys and values must be single-character Strings"
|
||||
end
|
||||
end
|
||||
@whitespace_characters = characters.dup
|
||||
end
|
||||
|
||||
# @param scope [String] "all" or "leading"
|
||||
# @return [String]
|
||||
def whitespace_scope=(scope)
|
||||
scope = Vamp.string!(scope, "whitespace_scope")
|
||||
raise ArgumentError, "whitespace_scope must be all or leading" unless %w[all leading].include?(scope)
|
||||
|
||||
@whitespace_scope = scope
|
||||
end
|
||||
|
||||
# @param lines [Integer] nonnegative context lines around each diff change
|
||||
# @return [Integer]
|
||||
def diff_context_lines=(lines)
|
||||
@@ -275,6 +324,11 @@ module Vamp
|
||||
"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,
|
||||
"git_gutter" => @git_gutter,
|
||||
"scroll_distance" => @scroll_distance,
|
||||
"highlight_trailing_whitespace" => @highlight_trailing_whitespace,
|
||||
"whitespace_characters" => @whitespace_characters,
|
||||
"whitespace_scope" => @whitespace_scope,
|
||||
"top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels,
|
||||
"popup" => { "command" => @popup.command, "search" => @popup.search },
|
||||
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
|
||||
|
||||
+440
-30
@@ -84,12 +84,13 @@ impl DiffWorker {
|
||||
} => repository
|
||||
.compare(id, &before, &after, mode, &buffers)
|
||||
.map(DiffOutcome::Comparison),
|
||||
DiffOperation::RefreshWorking {
|
||||
DiffOperation::RefreshCheckout {
|
||||
comparison_id,
|
||||
follow_working,
|
||||
buffers,
|
||||
} => repository
|
||||
.refresh_working_after(comparison_id, id, &buffers)
|
||||
.map(DiffOutcome::WorkingRefresh),
|
||||
.refresh_checkout(comparison_id, id, follow_working, &buffers)
|
||||
.map(DiffOutcome::CheckoutRefresh),
|
||||
DiffOperation::LoadFile {
|
||||
comparison_id,
|
||||
file_index,
|
||||
@@ -164,6 +165,15 @@ struct AsyncRequests {
|
||||
latest: Option<(u64, InvocationSource)>,
|
||||
}
|
||||
|
||||
struct GutterSource {
|
||||
buffer: usize,
|
||||
revision: u64,
|
||||
save_revision: u64,
|
||||
path: PathBuf,
|
||||
dirty: bool,
|
||||
contents: Arc<str>,
|
||||
}
|
||||
|
||||
/// Owns command eligibility and execution hosts together. Frontends can inspect
|
||||
/// the editor, but cannot independently mutate its plugin registry.
|
||||
pub struct EditorSession {
|
||||
@@ -184,6 +194,12 @@ pub struct EditorSession {
|
||||
blame_source: Option<BlameSource>,
|
||||
blame_lines: Option<Vec<Option<Arc<crate::blame::LineBlame>>>>,
|
||||
blame_request_id: u64,
|
||||
gutter_worker: Option<crate::gutter::Worker>,
|
||||
gutter_sources: Vec<GutterSource>,
|
||||
gutter_request_id: u64,
|
||||
gutter_pending: bool,
|
||||
last_gutter_refresh: Option<Instant>,
|
||||
picker_source: Option<(usize, PathBuf)>,
|
||||
battery: Option<crate::battery::Battery>,
|
||||
battery_enabled: bool,
|
||||
last_file_refresh: Instant,
|
||||
@@ -239,6 +255,11 @@ impl EditorSession {
|
||||
));
|
||||
}
|
||||
editor.set_git_blame(config.git_blame);
|
||||
editor.set_gutter_style(config.git_gutter);
|
||||
editor.set_scroll_distance(config.scroll_distance);
|
||||
editor.set_highlight_trailing_whitespace(config.highlight_trailing_whitespace);
|
||||
editor.set_whitespace_characters(config.whitespace_characters);
|
||||
editor.set_whitespace_scope(config.whitespace_scope);
|
||||
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
|
||||
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
|
||||
if let Err(error) =
|
||||
@@ -292,6 +313,12 @@ impl EditorSession {
|
||||
blame_source: None,
|
||||
blame_lines: None,
|
||||
blame_request_id: 0,
|
||||
gutter_worker: None,
|
||||
gutter_sources: Vec::new(),
|
||||
gutter_request_id: 0,
|
||||
gutter_pending: false,
|
||||
last_gutter_refresh: None,
|
||||
picker_source: None,
|
||||
battery: None,
|
||||
battery_enabled: false,
|
||||
last_file_refresh: Instant::now(),
|
||||
@@ -300,6 +327,7 @@ impl EditorSession {
|
||||
};
|
||||
session.synchronize_documents();
|
||||
session.poll_battery();
|
||||
session.poll_gutter_at(Instant::now());
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
@@ -335,6 +363,14 @@ impl EditorSession {
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, input: Input) {
|
||||
if input == Input::Ctrl('r')
|
||||
&& self
|
||||
.editor
|
||||
.normal_file_picker()
|
||||
.is_some_and(|picker| picker.focused)
|
||||
{
|
||||
self.last_gutter_refresh = None;
|
||||
}
|
||||
self.cancel_pending_popup_request();
|
||||
let invocation = self.editor.handle(input);
|
||||
self.synchronize_documents();
|
||||
@@ -1034,6 +1070,218 @@ impl EditorSession {
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn poll_gutter_at(&mut self, now: Instant) -> bool {
|
||||
let line_gutter = self.editor.gutter_style() != crate::gutter::GutterStyle::None;
|
||||
let picker_changed = match (self.editor.normal_file_picker(), &self.picker_source) {
|
||||
(Some(picker), Some((tab, root))) => {
|
||||
*tab != self.editor.tab_id() || *root != picker.root
|
||||
}
|
||||
(None, None) => false,
|
||||
_ => true,
|
||||
};
|
||||
if self.editor.should_quit() || (!line_gutter && self.editor.normal_file_picker().is_none())
|
||||
{
|
||||
self.gutter_worker = None;
|
||||
self.gutter_sources.clear();
|
||||
self.gutter_pending = false;
|
||||
self.picker_source = None;
|
||||
return false;
|
||||
}
|
||||
let sources_changed = !self
|
||||
.editor
|
||||
.documents()
|
||||
.filter(|document| line_gutter || document.dirty)
|
||||
.filter_map(|document| {
|
||||
document.path.map(|path| {
|
||||
(
|
||||
document.id,
|
||||
document.revision,
|
||||
document.save_revision,
|
||||
path,
|
||||
document.dirty,
|
||||
)
|
||||
})
|
||||
})
|
||||
.eq(self.gutter_sources.iter().map(|source| {
|
||||
(
|
||||
source.buffer,
|
||||
source.revision,
|
||||
source.save_revision,
|
||||
source.path.as_path(),
|
||||
source.dirty,
|
||||
)
|
||||
}));
|
||||
let refresh_due = !self.gutter_pending
|
||||
&& self
|
||||
.last_gutter_refresh
|
||||
.is_none_or(|last| now.saturating_duration_since(last) >= Duration::from_secs(1));
|
||||
if sources_changed || picker_changed || refresh_due {
|
||||
if picker_changed {
|
||||
self.picker_source = self
|
||||
.editor
|
||||
.normal_file_picker()
|
||||
.map(|picker| (self.editor.tab_id(), picker.root.clone()));
|
||||
}
|
||||
if sources_changed {
|
||||
self.gutter_sources = self
|
||||
.editor
|
||||
.documents()
|
||||
.filter(|document| line_gutter || document.dirty)
|
||||
.filter_map(|document| {
|
||||
let path = document.path?;
|
||||
let contents = self
|
||||
.gutter_sources
|
||||
.binary_search_by_key(&document.id, |source| source.buffer)
|
||||
.ok()
|
||||
.map(|index| &self.gutter_sources[index])
|
||||
.filter(|source| {
|
||||
source.revision == document.revision && source.path == path
|
||||
})
|
||||
.map_or_else(
|
||||
|| Arc::from(document.lines.join("\n")),
|
||||
|source| Arc::clone(&source.contents),
|
||||
);
|
||||
Some(GutterSource {
|
||||
buffer: document.id,
|
||||
revision: document.revision,
|
||||
save_revision: document.save_revision,
|
||||
path: path.to_path_buf(),
|
||||
dirty: document.dirty,
|
||||
contents,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
self.gutter_request_id = self.gutter_request_id.wrapping_add(1);
|
||||
self.last_gutter_refresh = Some(now);
|
||||
if self.gutter_sources.is_empty() && self.picker_source.is_none() {
|
||||
self.gutter_worker = None;
|
||||
self.gutter_pending = false;
|
||||
return false;
|
||||
}
|
||||
if self.gutter_worker.is_none() {
|
||||
match crate::gutter::Worker::start() {
|
||||
Ok(worker) => self.gutter_worker = Some(worker),
|
||||
Err(error) => {
|
||||
self.editor.set_message(format!("Gutter: {error}"));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let files = self
|
||||
.gutter_sources
|
||||
.iter()
|
||||
.filter(|_| line_gutter)
|
||||
.map(|source| {
|
||||
(
|
||||
source.buffer,
|
||||
source.path.clone(),
|
||||
Arc::clone(&source.contents),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let picker = self.picker_source.as_ref().map(|(_, root)| {
|
||||
let buffers = self
|
||||
.gutter_sources
|
||||
.iter()
|
||||
.filter(|source| source.dirty)
|
||||
.map(|source| (source.path.clone(), Arc::clone(&source.contents)))
|
||||
.collect();
|
||||
(root.clone(), buffers)
|
||||
});
|
||||
match self
|
||||
.gutter_worker
|
||||
.as_mut()
|
||||
.expect("gutter worker started")
|
||||
.request(self.gutter_request_id, files, picker)
|
||||
{
|
||||
Ok(()) => self.gutter_pending = true,
|
||||
Err(error) => {
|
||||
self.gutter_worker = None;
|
||||
self.gutter_pending = false;
|
||||
self.editor.set_message(format!("Gutter: {error}"));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut changed = false;
|
||||
while let Some((id, result, picker)) = self
|
||||
.gutter_worker
|
||||
.as_mut()
|
||||
.and_then(crate::gutter::Worker::poll)
|
||||
{
|
||||
changed |= self.apply_gutter_result(id, result);
|
||||
if let Some(picker) = picker {
|
||||
changed |= self.apply_picker_result(id, picker);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn apply_gutter_result(&mut self, id: u64, result: crate::gutter::ResultBatch) -> bool {
|
||||
if id != self.gutter_request_id {
|
||||
return false;
|
||||
}
|
||||
self.gutter_pending = false;
|
||||
let mut changed = false;
|
||||
for (buffer, result) in result {
|
||||
let Some(source) = self
|
||||
.gutter_sources
|
||||
.iter()
|
||||
.find(|source| source.buffer == buffer)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let changes = match result {
|
||||
Ok(changes) => changes,
|
||||
Err(error) => {
|
||||
self.editor.set_message(format!("Gutter: {error}"));
|
||||
changed = true;
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
changed |=
|
||||
self.editor
|
||||
.set_gutter_changes(buffer, source.revision, &source.path, changes);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn apply_picker_result(&mut self, id: u64, reply: crate::gutter::PickerChanges) -> bool {
|
||||
let Some((tab, root)) = &self.picker_source else {
|
||||
return false;
|
||||
};
|
||||
if id != self.gutter_request_id
|
||||
|| *tab != self.editor.tab_id()
|
||||
|| *root != reply.root
|
||||
|| self
|
||||
.editor
|
||||
.normal_file_picker()
|
||||
.is_none_or(|picker| picker.root != *root)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let tab = *tab;
|
||||
let mut changed = false;
|
||||
let changes = match reply.result {
|
||||
Ok(changes) => changes,
|
||||
Err(error) => {
|
||||
self.editor.set_message(format!("File status: {error}"));
|
||||
changed = true;
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
changed
|
||||
| self
|
||||
.editor
|
||||
.set_picker_repository_changes(tab, &reply.root, changes)
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_gutter(&mut self) -> bool {
|
||||
self.last_gutter_refresh = None;
|
||||
self.poll_gutter_at(Instant::now())
|
||||
}
|
||||
|
||||
fn poll_blame(&mut self) -> bool {
|
||||
let mut changed = false;
|
||||
if !self
|
||||
@@ -1151,14 +1399,13 @@ 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)
|
||||
{
|
||||
fn refresh_checkout_diffs_at(&mut self, now: Instant) {
|
||||
if now.saturating_duration_since(self.last_diff_refresh) < Duration::from_secs(1) {
|
||||
return;
|
||||
}
|
||||
self.last_diff_refresh = now;
|
||||
self.editor.queue_working_refreshes();
|
||||
self.editor
|
||||
.queue_checkout_refreshes(self.diff_follow_working_copy);
|
||||
}
|
||||
|
||||
/// Drains ready plugin work without waiting for command or server responses.
|
||||
@@ -1168,9 +1415,10 @@ impl EditorSession {
|
||||
|
||||
fn poll_at(&mut self, now: Instant) -> bool {
|
||||
let mut changed = self.refresh_files_at(now);
|
||||
changed |= self.poll_gutter_at(now);
|
||||
changed |= self.synchronize_documents();
|
||||
changed |= self.poll_battery();
|
||||
self.refresh_working_diffs_at(now);
|
||||
self.refresh_checkout_diffs_at(now);
|
||||
changed |= self.poll_diff();
|
||||
for update in self.lsp.poll() {
|
||||
match self.apply_update("core", update) {
|
||||
@@ -1234,7 +1482,172 @@ mod tests {
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn working_diff_polling_uses_injected_interval_and_never_queues_historical_or_disabled_views() {
|
||||
fn normal_picker_tracks_unsaved_text_and_head_with_line_gutter_disabled() {
|
||||
use crate::diff::FileStatus;
|
||||
let fixture = Fixture::new();
|
||||
fixture.file("buffer.txt", "base\n");
|
||||
fixture.file("clean.txt", "clean\n");
|
||||
let git = |args: &[&str]| {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&fixture.0)
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_AUTHOR_NAME", "Author")
|
||||
.env("GIT_AUTHOR_EMAIL", "author@example.com")
|
||||
.env("GIT_COMMITTER_NAME", "Author")
|
||||
.env("GIT_COMMITTER_EMAIL", "author@example.com")
|
||||
.env("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z")
|
||||
.env("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
};
|
||||
git(&["init", "-q", "--template="]);
|
||||
git(&["add", "buffer.txt", "clean.txt"]);
|
||||
git(&["commit", "-qm", "baseline"]);
|
||||
fixture.file("buffer.txt", "disk\n");
|
||||
fixture.file("added.txt", "new\n");
|
||||
let mut session = fixture.open(
|
||||
"Vamp.configure do |config|\nconfig.git_gutter = \"none\"\nconfig.git_blame = false\nend\n",
|
||||
);
|
||||
session.editor.set_project_root(fixture.0.clone());
|
||||
let now = session.last_file_refresh;
|
||||
let open = |session: &mut EditorSession| {
|
||||
session.handle(Input::Char(' '));
|
||||
session.handle(Input::Char('o'));
|
||||
};
|
||||
let ready = |session: &mut EditorSession, clock: Instant, expected| {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
session.poll_gutter_at(clock);
|
||||
let picker = session.editor.normal_file_picker().unwrap();
|
||||
let status = picker
|
||||
.entries()
|
||||
.find(|row| row.path == Path::new("buffer.txt"))
|
||||
.expect("normal picker retained the buffer file")
|
||||
.status;
|
||||
if !session.gutter_pending && status == expected {
|
||||
assert!(
|
||||
picker.entries().any(|row| {
|
||||
row.path == Path::new("clean.txt") && row.status.is_none()
|
||||
})
|
||||
);
|
||||
assert!(picker.entries().any(|row| {
|
||||
row.path == Path::new("added.txt") && row.status == Some(FileStatus::Added)
|
||||
}));
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"normal picker status did not settle: {}",
|
||||
session.editor.message()
|
||||
);
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
};
|
||||
open(&mut session);
|
||||
ready(&mut session, now, Some(FileStatus::Modified));
|
||||
session.handle(Input::Ctrl('w'));
|
||||
session.poll_gutter_at(now);
|
||||
session
|
||||
.editor
|
||||
.apply(vec![Effect::Replace("base\n".into())])
|
||||
.unwrap();
|
||||
assert!(session.editor.dirty());
|
||||
open(&mut session);
|
||||
ready(&mut session, now, None);
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
|
||||
"disk\n"
|
||||
);
|
||||
session.handle(Input::Ctrl('w'));
|
||||
session.poll_gutter_at(now);
|
||||
session.handle(Input::Char('u'));
|
||||
open(&mut session);
|
||||
ready(&mut session, now, Some(FileStatus::Modified));
|
||||
git(&["add", "buffer.txt"]);
|
||||
git(&["commit", "-qm", "new baseline"]);
|
||||
ready(&mut session, now + Duration::from_secs(1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gutter_results_follow_buffer_identity_and_reject_stale_edits() {
|
||||
use crate::gutter::Change;
|
||||
let fixture = Fixture::new();
|
||||
let first_path = fixture.file("first.txt", "first\nsecond");
|
||||
let second_path = fixture.file("second.txt", "other");
|
||||
let mut session = EditorSession::open(Some(first_path), vec![], None).unwrap();
|
||||
let now = session.last_gutter_refresh.unwrap();
|
||||
let first_buffer = session.editor.buffer_id();
|
||||
let first_request = session.gutter_request_id;
|
||||
assert!(session.apply_gutter_result(
|
||||
first_request,
|
||||
vec![(first_buffer, Ok(vec![None, Some(Change::Modified)]))],
|
||||
));
|
||||
session.editor.handle(Input::Down);
|
||||
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
|
||||
session
|
||||
.editor
|
||||
.execute(&format!("edit {}", second_path.display()));
|
||||
session.poll_gutter_at(now);
|
||||
let second_buffer = session.editor.buffer_id();
|
||||
let second_request = session.gutter_request_id;
|
||||
assert_ne!(second_request, first_request);
|
||||
assert!(!session.apply_gutter_result(
|
||||
first_request,
|
||||
vec![(first_buffer, Ok(vec![Some(Change::Removed), None]))],
|
||||
));
|
||||
assert!(session.apply_gutter_result(
|
||||
second_request,
|
||||
vec![
|
||||
(first_buffer, Ok(vec![None, Some(Change::Modified)])),
|
||||
(second_buffer, Ok(vec![Some(Change::Added)])),
|
||||
],
|
||||
));
|
||||
assert_eq!(session.editor.gutter_change(0), Some(Change::Added));
|
||||
session.editor.execute(&format!("buffer {first_buffer}"));
|
||||
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
|
||||
session.editor.handle(Input::Char('i'));
|
||||
session.editor.handle(Input::Char('x'));
|
||||
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
|
||||
session.editor.handle(Input::Char('y'));
|
||||
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
|
||||
session.editor.handle(Input::Backspace);
|
||||
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
|
||||
session.editor.handle(Input::Escape);
|
||||
session.editor.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
|
||||
session.editor.handle(Input::Char('U'));
|
||||
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
|
||||
assert!(!session.apply_gutter_result(
|
||||
second_request,
|
||||
vec![(first_buffer, Ok(vec![None, Some(Change::Removed)]))],
|
||||
));
|
||||
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
|
||||
session.poll_gutter_at(now);
|
||||
assert_eq!(session.editor.gutter_change(1), Some(Change::Modified));
|
||||
assert!(session.apply_gutter_result(
|
||||
session.gutter_request_id,
|
||||
vec![(first_buffer, Ok(vec![None, Some(Change::Added)]))],
|
||||
));
|
||||
assert_eq!(session.editor.gutter_change(1), Some(Change::Added));
|
||||
session.poll_gutter_at(now + Duration::from_secs(1));
|
||||
assert!(session.apply_gutter_result(
|
||||
session.gutter_request_id,
|
||||
vec![(first_buffer, Ok(vec![None, None]))],
|
||||
));
|
||||
assert_eq!(session.editor.gutter_change(1), None);
|
||||
session.editor.execute(&format!("buffer {second_buffer}"));
|
||||
assert_eq!(session.editor.gutter_change(0), Some(Change::Added));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_diff_polling_uses_injected_interval_independently_of_endpoint_following() {
|
||||
use crate::diff::{Catalog, Category, Comparison, RepoKind, Revision, RevisionTarget};
|
||||
let fixture = Fixture::new();
|
||||
fixture.file("buffer.txt", "base");
|
||||
@@ -1279,27 +1692,24 @@ mod tests {
|
||||
})),
|
||||
);
|
||||
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));
|
||||
for (tick, follow) in [(1, false), (2, true)] {
|
||||
session.diff_follow_working_copy = follow;
|
||||
session.refresh_checkout_diffs_at(now + Duration::from_millis(tick * 1000 - 1));
|
||||
assert!(session.editor.take_next_diff_request().is_none());
|
||||
assert!(
|
||||
!session
|
||||
.editor
|
||||
.apply_diff_result(refresh, Ok(DiffOutcome::WorkingRefresh(None)))
|
||||
);
|
||||
session.refresh_checkout_diffs_at(now + Duration::from_secs(tick));
|
||||
let request = session.editor.take_next_diff_request();
|
||||
assert_eq!(request.is_some(), !working || follow);
|
||||
if let Some((_, refresh, operation)) = request {
|
||||
assert!(
|
||||
matches!(operation, DiffOperation::RefreshCheckout { comparison_id, follow_working, .. }
|
||||
if comparison_id == id && follow_working == follow)
|
||||
);
|
||||
assert!(
|
||||
!session
|
||||
.editor
|
||||
.apply_diff_result(refresh, Ok(DiffOutcome::CheckoutRefresh(None)))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+114
-25
@@ -24,7 +24,7 @@ pub enum SyntaxKind {
|
||||
Markup,
|
||||
}
|
||||
|
||||
/// A syntax foreground range in the original line's bytes, excluding its newline.
|
||||
/// A syntax range in original line bytes; string coverage may include its newline.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SyntaxSpan {
|
||||
pub start_byte: usize,
|
||||
@@ -37,12 +37,14 @@ pub struct SyntaxSpan {
|
||||
pub struct SyntaxCache {
|
||||
revision: Option<u64>,
|
||||
path: Option<PathBuf>,
|
||||
final_newline: bool,
|
||||
syntax: Option<&'static LanguageSpec>,
|
||||
source: String,
|
||||
next_source: String,
|
||||
line_starts: Vec<usize>,
|
||||
lines: Vec<Vec<SyntaxSpan>>,
|
||||
highlighted: Vec<bool>,
|
||||
string_spans: Vec<Vec<SyntaxSpan>>,
|
||||
context: Option<LayerContext>,
|
||||
parser: Parser,
|
||||
tree: Option<Tree>,
|
||||
@@ -88,6 +90,7 @@ impl SyntaxCache {
|
||||
path,
|
||||
revision,
|
||||
std::slice::from_ref(&(0..lines.len())),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,7 +103,26 @@ impl SyntaxCache {
|
||||
revision: u64,
|
||||
visible: &[std::ops::Range<usize>],
|
||||
) {
|
||||
self.update_rows(lines, path, revision, visible);
|
||||
self.update_rows(lines, path, revision, visible, false);
|
||||
}
|
||||
|
||||
/// Diff sources store a final line break separately instead of a trailing empty row.
|
||||
pub(crate) fn update_diff(
|
||||
&mut self,
|
||||
lines: &[String],
|
||||
path: Option<&Path>,
|
||||
revision: u64,
|
||||
final_newline: bool,
|
||||
visible: Option<&[std::ops::Range<usize>]>,
|
||||
) {
|
||||
let all_rows = 0..lines.len();
|
||||
self.update_rows(
|
||||
lines,
|
||||
path,
|
||||
revision,
|
||||
visible.unwrap_or(std::slice::from_ref(&all_rows)),
|
||||
final_newline && !lines.is_empty(),
|
||||
);
|
||||
}
|
||||
|
||||
fn update_rows(
|
||||
@@ -109,9 +131,11 @@ impl SyntaxCache {
|
||||
path: Option<&Path>,
|
||||
revision: u64,
|
||||
visible: &[std::ops::Range<usize>],
|
||||
final_newline: bool,
|
||||
) {
|
||||
let path_changed = self.path.as_deref() != path;
|
||||
let revision_changed = self.revision != Some(revision);
|
||||
let revision_changed =
|
||||
self.revision != Some(revision) || self.final_newline != final_newline;
|
||||
let syntax = if path_changed || revision_changed {
|
||||
languages::detect(path, lines.first().map(String::as_str))
|
||||
} else {
|
||||
@@ -119,6 +143,7 @@ impl SyntaxCache {
|
||||
};
|
||||
let language_changed = !same_language(self.syntax, syntax);
|
||||
self.revision = Some(revision);
|
||||
self.final_newline = final_newline;
|
||||
if path_changed {
|
||||
self.path = path.map(Path::to_path_buf);
|
||||
}
|
||||
@@ -127,8 +152,9 @@ impl SyntaxCache {
|
||||
let mut edit = None;
|
||||
if revision_changed {
|
||||
self.next_source.clear();
|
||||
let length =
|
||||
lines.iter().map(String::len).sum::<usize>() + lines.len().saturating_sub(1);
|
||||
let length = lines.iter().map(String::len).sum::<usize>()
|
||||
+ lines.len().saturating_sub(1)
|
||||
+ usize::from(final_newline);
|
||||
self.next_source.reserve(length);
|
||||
for (row, line) in lines.iter().enumerate() {
|
||||
if row > 0 {
|
||||
@@ -136,6 +162,9 @@ impl SyntaxCache {
|
||||
}
|
||||
self.next_source.push_str(line);
|
||||
}
|
||||
if final_newline {
|
||||
self.next_source.push('\n');
|
||||
}
|
||||
if self.source != self.next_source {
|
||||
edit = Some(minimal_edit(&self.source, &self.next_source));
|
||||
std::mem::swap(&mut self.source, &mut self.next_source);
|
||||
@@ -146,11 +175,15 @@ impl SyntaxCache {
|
||||
self.line_starts.push(start);
|
||||
start += line.len() + 1;
|
||||
}
|
||||
if final_newline {
|
||||
self.line_starts.push(self.source.len());
|
||||
}
|
||||
self.lines.resize_with(lines.len(), Vec::new);
|
||||
self.string_spans.resize_with(lines.len(), Vec::new);
|
||||
self.highlighted.resize(lines.len(), false);
|
||||
}
|
||||
if edit.is_some() || language_changed {
|
||||
for spans in &mut self.lines {
|
||||
for spans in self.lines.iter_mut().chain(&mut self.string_spans) {
|
||||
spans.clear();
|
||||
}
|
||||
self.highlighted.fill(false);
|
||||
@@ -183,6 +216,8 @@ impl SyntaxCache {
|
||||
start_point: Point::new(start, 0),
|
||||
end_point: if row < lines.len() {
|
||||
Point::new(row, 0)
|
||||
} else if final_newline {
|
||||
Point::new(lines.len(), 0)
|
||||
} else {
|
||||
Point::new(
|
||||
lines.len().saturating_sub(1),
|
||||
@@ -209,7 +244,7 @@ impl SyntaxCache {
|
||||
self.injections.clear();
|
||||
self.context = None;
|
||||
self.highlighted.fill(false);
|
||||
for spans in &mut self.lines {
|
||||
for spans in self.lines.iter_mut().chain(&mut self.string_spans) {
|
||||
spans.clear();
|
||||
}
|
||||
} else {
|
||||
@@ -225,6 +260,12 @@ impl SyntaxCache {
|
||||
self.lines.get(row).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// String coverage before foreground overrides, including embedded-language highlighting.
|
||||
/// An included line break has end_byte == line.len() + 1; foreground spans exclude it.
|
||||
pub(crate) fn string_spans(&self, row: usize) -> &[SyntaxSpan] {
|
||||
self.string_spans.get(row).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// Bundled grammar name, or `None` for unknown languages and plain text.
|
||||
pub fn language(&self) -> Option<&str> {
|
||||
self.syntax.map(|syntax| syntax.name)
|
||||
@@ -415,6 +456,7 @@ impl SyntaxCache {
|
||||
&self.source,
|
||||
&self.line_starts,
|
||||
&mut self.lines,
|
||||
&mut self.string_spans,
|
||||
&self.highlights,
|
||||
&mut self.events,
|
||||
visible,
|
||||
@@ -1089,6 +1131,7 @@ fn render_spans(
|
||||
source: &str,
|
||||
starts: &[usize],
|
||||
lines: &mut [Vec<SyntaxSpan>],
|
||||
strings: &mut [Vec<SyntaxSpan>],
|
||||
highlights: &[Highlight],
|
||||
events: &mut Vec<(usize, bool, usize)>,
|
||||
visible: &[Range],
|
||||
@@ -1111,24 +1154,34 @@ fn render_spans(
|
||||
};
|
||||
let mut active: BTreeSet<(_, usize)> = BTreeSet::new();
|
||||
let mut previous = 0;
|
||||
let mut active_strings = 0;
|
||||
for &(byte, entering, index) in events.iter() {
|
||||
if previous < byte
|
||||
&& let Some((_, current)) = active.last()
|
||||
&& let Some(kind) = highlights[*current].kind
|
||||
{
|
||||
let start = visible.partition_point(|range| range.end_byte <= previous);
|
||||
for range in visible[start..]
|
||||
.iter()
|
||||
.take_while(|range| range.start_byte < byte)
|
||||
{
|
||||
append_global(
|
||||
source,
|
||||
starts,
|
||||
lines,
|
||||
previous.max(range.start_byte),
|
||||
byte.min(range.end_byte),
|
||||
kind,
|
||||
);
|
||||
if previous < byte {
|
||||
let kind = active
|
||||
.last()
|
||||
.and_then(|(_, current)| highlights[*current].kind);
|
||||
if kind.is_some() || active_strings > 0 {
|
||||
let start = visible.partition_point(|range| range.end_byte <= previous);
|
||||
for range in visible[start..]
|
||||
.iter()
|
||||
.take_while(|range| range.start_byte < byte)
|
||||
{
|
||||
let low = previous.max(range.start_byte);
|
||||
let high = byte.min(range.end_byte);
|
||||
if let Some(kind) = kind {
|
||||
append_global(source, starts, lines, low, high, kind, false);
|
||||
}
|
||||
if active_strings > 0 {
|
||||
append_global(source, starts, strings, low, high, SyntaxKind::String, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if highlights[index].kind == Some(SyntaxKind::String) {
|
||||
if entering {
|
||||
active_strings += 1;
|
||||
} else {
|
||||
active_strings -= 1;
|
||||
}
|
||||
}
|
||||
if entering {
|
||||
@@ -1147,6 +1200,7 @@ fn append_global(
|
||||
mut start: usize,
|
||||
end: usize,
|
||||
kind: SyntaxKind,
|
||||
include_newline: bool,
|
||||
) {
|
||||
if starts.is_empty() {
|
||||
return;
|
||||
@@ -1156,7 +1210,7 @@ fn append_global(
|
||||
.saturating_sub(1);
|
||||
while start < end && row < lines.len() {
|
||||
let line_end = starts.get(row + 1).map_or(source.len(), |next| next - 1);
|
||||
let high = end.min(line_end);
|
||||
let high = end.min(line_end + usize::from(include_newline && row + 1 < starts.len()));
|
||||
if start < high {
|
||||
let spans = &mut lines[row];
|
||||
let low = start - starts[row];
|
||||
@@ -1196,6 +1250,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_newlines_remain_literal_across_visible_row_caching_and_edits() {
|
||||
let source = "text = \"\"\"a\n\nb\"\"\"\nplain\n";
|
||||
let input = lines(source);
|
||||
let path = Path::new("strings.py");
|
||||
let mut cache = SyntaxCache::new();
|
||||
let includes_newline = |cache: &SyntaxCache, row: usize, length: usize| {
|
||||
cache
|
||||
.string_spans(row)
|
||||
.iter()
|
||||
.any(|span| span.start_byte <= length && span.end_byte > length)
|
||||
};
|
||||
cache.update_visible(&input, Some(path), 0, std::slice::from_ref(&(0..1)));
|
||||
assert!(includes_newline(&cache, 0, input[0].len()));
|
||||
cache.update_visible(&input, Some(path), 0, std::slice::from_ref(&(1..4)));
|
||||
assert!(includes_newline(&cache, 0, input[0].len()));
|
||||
assert!(includes_newline(&cache, 1, input[1].len()));
|
||||
assert!(!includes_newline(&cache, 2, input[2].len()));
|
||||
assert!(!includes_newline(&cache, 3, input[3].len()));
|
||||
for (row, line) in input.iter().enumerate() {
|
||||
assert!(
|
||||
cache
|
||||
.line(row)
|
||||
.iter()
|
||||
.all(|span| span.end_byte <= line.len())
|
||||
);
|
||||
}
|
||||
cache.update(&lines("# a\n\nb\nplain\n"), Some(path), 1);
|
||||
assert!(!includes_newline(&cache, 0, 3));
|
||||
assert!(!includes_newline(&cache, 1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_windows_match_full_colors_after_edits_and_scrolling_without_reparsing() {
|
||||
let cases = [
|
||||
@@ -1893,6 +1979,7 @@ mod tests {
|
||||
source,
|
||||
&[0],
|
||||
&mut spans,
|
||||
&mut vec![Vec::new(); 1],
|
||||
&highlights,
|
||||
&mut events,
|
||||
&[source_range(source)],
|
||||
@@ -1963,6 +2050,7 @@ mod tests {
|
||||
source,
|
||||
&starts,
|
||||
&mut spans,
|
||||
&mut vec![Vec::new(); 3],
|
||||
&highlights,
|
||||
&mut Vec::new(),
|
||||
&[source_range(source)],
|
||||
@@ -2004,6 +2092,7 @@ mod tests {
|
||||
source,
|
||||
&starts,
|
||||
&mut visible_spans,
|
||||
&mut vec![Vec::new(); 3],
|
||||
&visible_highlights,
|
||||
&mut Vec::new(),
|
||||
&window,
|
||||
|
||||
+2207
-158
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -548,7 +548,8 @@ fn branch_changes_default_and_mode_switches_compare_the_selected_divergent_tips(
|
||||
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!(session.editor().context().text, "feature branch\n");
|
||||
assert!(diff.after_editable());
|
||||
assert_eq!(
|
||||
comparison.before,
|
||||
format!("merge base of {before} and {after} · {base}")
|
||||
@@ -590,7 +591,6 @@ fn branch_changes_default_and_mode_switches_compare_the_selected_divergent_tips(
|
||||
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| {
|
||||
|
||||
@@ -558,6 +558,7 @@ fn diff_repository(fixture: &Fixture, historical: bool) -> (String, Option<Strin
|
||||
repository(fixture, &["add", "."]);
|
||||
repository(fixture, &["commit", "-qm", "after"]);
|
||||
let after = repository(fixture, &["rev-parse", "HEAD"]);
|
||||
repository(fixture, &["checkout", "--detach", &before]);
|
||||
fs::write(&fixture.source, "disk is not the historical source\n").unwrap();
|
||||
fs::write(&fixture.target, "disk is not the historical target\n").unwrap();
|
||||
after
|
||||
|
||||
+2
-1
@@ -275,6 +275,7 @@ fn readonly_before_and_historical_after_seeks_preserve_snapshots_and_folded_exce
|
||||
fs::write(&source, &after_text).unwrap();
|
||||
fixture.git(&["commit", "-qam", "after"]);
|
||||
let after = fixture.git(&["rev-parse", "HEAD"]);
|
||||
fixture.git(&["checkout", "--detach", &before]);
|
||||
lines[19] = "working x x tail".into();
|
||||
let working_text = lines.join("\n") + "\n";
|
||||
fs::write(&source, &working_text).unwrap();
|
||||
@@ -386,5 +387,5 @@ fn readonly_before_and_historical_after_seeks_preserve_snapshots_and_folded_exce
|
||||
assert_eq!(session.editor().cursor(), original_cursor);
|
||||
assert!(session.editor().dirty());
|
||||
assert_eq!(fs::read_to_string(&source).unwrap(), working_text);
|
||||
assert_eq!(fixture.git(&["rev-parse", "HEAD"]), after);
|
||||
assert_eq!(fixture.git(&["rev-parse", "HEAD"]), before);
|
||||
}
|
||||
|
||||
+243
-8
@@ -42,6 +42,89 @@ fn keys(editor: &mut Editor, text: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_r_replaces_selected_graphemes_as_one_undo_edit() {
|
||||
let fixture = Fixture::new();
|
||||
let source = "ae\u{301}👩🚀z\nrest\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "lv2l");
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
keys(&mut editor, "r界");
|
||||
assert_eq!(editor.context().text, "a界界界\nrest\n");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert!(!editor.dirty());
|
||||
keys(&mut editor, "U");
|
||||
assert_eq!(editor.context().text, "a界界界\nrest\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_r_preserves_selected_newlines_in_both_directions_and_shapes() {
|
||||
for (source, selection, expected) in [
|
||||
("ae\u{301}👩🚀z\n\nbc\nlast\n", "v2j", "QQQQ\n\nQc\nlast\n"),
|
||||
("ae\u{301}👩🚀z\n\nbc\nlast\n", "2jv2k", "QQQQ\n\nQc\nlast\n"),
|
||||
("ae\u{301}👩🚀z\n\nbc\nlast\n", "V2j", "QQQQ\n\nQQ\nlast\n"),
|
||||
("ab\ncd", "Vj", "QQ\nQQ"),
|
||||
("\n\n", "Vj", "\n\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, selection);
|
||||
keys(&mut editor, "rQ");
|
||||
assert_eq!(editor.context().text, expected, "{selection}");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert_eq!(editor.dirty(), source != expected);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_r_replaces_every_selection_and_restores_carets_on_undo() {
|
||||
let fixture = Fixture::new();
|
||||
let source = "abcd\nwxyz\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, "vl");
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
let carets = editor.carets();
|
||||
assert_eq!(editor.selection_count(), 2);
|
||||
keys(&mut editor, "r界");
|
||||
assert_eq!(editor.context().text, "界界cd\n界界yz\n");
|
||||
assert_eq!(editor.selection_count(), 2);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_r_repeats_selection_intent_without_overwriting_the_clipboard() {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("abc\nuvwxyz\n");
|
||||
keys(&mut editor, "yyvlrQj0.");
|
||||
assert_eq!(editor.context().text, "QQc\nQQwxyz\n");
|
||||
keys(&mut editor, "4lv.");
|
||||
assert_eq!(editor.context().text, "QQc\nQQwxQz\n");
|
||||
keys(&mut editor, "P");
|
||||
assert_eq!(editor.context().text, "QQc\nabc\nQQwxQz\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_and_identical_visual_replacements_preserve_redo() {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("abc\n");
|
||||
keys(&mut editor, "rQulvr");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
assert_eq!(editor.line_operator_pending(), None);
|
||||
keys(&mut editor, "rb");
|
||||
assert_eq!(editor.context().text, "abc\n");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
assert!(!editor.dirty());
|
||||
keys(&mut editor, "U");
|
||||
assert_eq!(editor.context().text, "Qbc\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_and_delete_line_suffix_preserve_newlines_and_group_insert_undo() {
|
||||
for operation in ["C", "D", "9C", "9D"] {
|
||||
@@ -191,7 +274,7 @@ fn substitute_deletes_counted_whole_graphemes_and_groups_replacement_with_undo()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_delete_counts_before_and_after_operator_multiply_in_both_directions() {
|
||||
fn word_cut_counts_before_and_after_operator_multiply_in_both_directions() {
|
||||
let source = "one two three four five six seven eight nine\n";
|
||||
for (position, command, expected) in [
|
||||
("", "dw", "two three four five six seven eight nine\n"),
|
||||
@@ -202,31 +285,77 @@ fn word_delete_counts_before_and_after_operator_multiply_in_both_directions() {
|
||||
("4w", "d2b", "one two five six seven eight nine\n"),
|
||||
("4w", "2db", "one two five six seven eight nine\n"),
|
||||
("6w", "2d3b", "seven eight nine\n"),
|
||||
] {
|
||||
for operator in ["d", "c"] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, position);
|
||||
let caret = editor.cursor();
|
||||
let command = command.replacen('d', operator, 1);
|
||||
keys(&mut editor, &command);
|
||||
assert_eq!(editor.context().text, expected, "{position} {command}");
|
||||
assert_eq!(
|
||||
editor.mode(),
|
||||
if operator == "c" {
|
||||
Mode::Insert
|
||||
} else {
|
||||
Mode::Normal
|
||||
}
|
||||
);
|
||||
if operator == "c" {
|
||||
keys(&mut editor, "Q");
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_changes_preserve_line_boundaries_and_group_replacement_with_undo() {
|
||||
for (source, position, command, after_cut, expected) in [
|
||||
("one two\nnext\n", "", "cw", "two\nnext\n", "Qtwo\nnext\n"),
|
||||
("one two\nnext\n", "w", "cb", "two\nnext\n", "Qtwo\nnext\n"),
|
||||
("one\ntwo\n", "", "c9w", "\ntwo\n", "Q\ntwo\n"),
|
||||
("one\ntwo\n", "j0", "c9b", "one\ntwo\n", "one\nQtwo\n"),
|
||||
("one\n\ntwo\n", "j", "cw", "one\n\ntwo\n", "one\nQ\ntwo\n"),
|
||||
("one\n\ntwo\n", "j", "cb", "one\n\ntwo\n", "one\nQ\ntwo\n"),
|
||||
("one\n \ntwo\n", "j", "cw", "one\n\ntwo\n", "one\nQ\ntwo\n"),
|
||||
("one \nnext\n", "", "cw", "\nnext\n", "Q\nnext\n"),
|
||||
("e\u{301}lan 世界 fin\n", "", "c2w", "fin\n", "Qfin\n"),
|
||||
("e\u{301}lan 世界", "$", "c9b", "界", "Q界"),
|
||||
("👩🚀!!! next\n", "", "cw", "next\n", "Qnext\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, position);
|
||||
let caret = editor.cursor();
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.context().text, after_cut, "{position} {command}");
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
keys(&mut editor, "Q");
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.context().text, expected, "{position} {command}");
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
assert!(!editor.dirty());
|
||||
keys(&mut editor, "U");
|
||||
assert_eq!(editor.context().text, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_delete_uses_unicode_word_motion_boundaries_across_punctuation_and_rows() {
|
||||
fn word_delete_uses_unicode_word_motion_boundaries_across_punctuation() {
|
||||
for (source, command, expected) in [
|
||||
("e\u{301}lan 世界 fin\n", "d2w", "fin\n"),
|
||||
("e\u{301}lan 世界 fin\n", "2wd2b", "fin\n"),
|
||||
("alpha... beta gamma\n", "d2w", "beta gamma\n"),
|
||||
("alpha... beta gamma\n", "2wd2b", "beta gamma\n"),
|
||||
("👩🚀!!! next\n", "dw", "next\n"),
|
||||
("one\ntwo three\n", "d2w", "three\n"),
|
||||
("one\ntwo three\n", "2wd2b", "three\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
@@ -237,6 +366,56 @@ fn word_delete_uses_unicode_word_motion_boundaries_across_punctuation_and_rows()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_delete_stops_at_current_line_boundaries() {
|
||||
for (source, position, command, expected) in [
|
||||
("one\ntwo three\n", "", "dw", "\ntwo three\n"),
|
||||
("one\ntwo three\n", "", "d2w", "\ntwo three\n"),
|
||||
("one two\nthree four\n", "w", "2d3w", "one \nthree four\n"),
|
||||
("one\ntwo three\n", "2w", "d2b", "one\nthree\n"),
|
||||
("one\n two three\n", "jw", "2d3b", "one\ntwo three\n"),
|
||||
("one \ntwo\n", "", "d9w", "\ntwo\n"),
|
||||
("one\n \ntwo\n", "j", "dw", "one\n\ntwo\n"),
|
||||
("one\n\ntwo\n", "j", "d9w", "one\n\ntwo\n"),
|
||||
("one\n\ntwo\n", "j", "d9b", "one\n\ntwo\n"),
|
||||
("one\ntwo\n", "j0", "db", "one\ntwo\n"),
|
||||
("one\n two\n", "jl", "db", "one\n two\n"),
|
||||
(
|
||||
"one\ne\u{301}lan 世界\nnext\n",
|
||||
"j0",
|
||||
"d9w",
|
||||
"one\n\nnext\n",
|
||||
),
|
||||
(
|
||||
"one\ne\u{301}lan 世界\nnext\n",
|
||||
"j$",
|
||||
"d9b",
|
||||
"one\n界\nnext\n",
|
||||
),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
keys(&mut editor, position);
|
||||
let caret = editor.cursor();
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(
|
||||
editor.context().text,
|
||||
expected,
|
||||
"{position} {command} on {source:?}"
|
||||
);
|
||||
assert_eq!(editor.mode(), Mode::Normal);
|
||||
if source == expected {
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
assert!(!editor.dirty());
|
||||
} else {
|
||||
keys(&mut editor, "u");
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.cursor(), caret);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_delete_handles_eof_and_saturating_counts_without_splitting_graphemes() {
|
||||
let source = "e\u{301}lan 世界";
|
||||
@@ -258,7 +437,7 @@ fn word_delete_handles_eof_and_saturating_counts_without_splitting_graphemes() {
|
||||
|
||||
#[test]
|
||||
fn pending_operator_and_replace_cancellation_clear_counts_without_editing() {
|
||||
for prefix in ["3d", "d2", "2d3", "3c", "c2", "3r"] {
|
||||
for prefix in ["3d", "d2", "2d3", "3c", "c2", "2c3", "3r"] {
|
||||
let fixture = Fixture::new();
|
||||
let source = "abcd\nnext\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
@@ -331,6 +510,42 @@ fn new_edit_recipes_repeat_against_the_current_text_and_undo_as_single_changes()
|
||||
"j02w",
|
||||
"keep\nstay\n",
|
||||
),
|
||||
("one\nthree four\n", "d9w", "\nthree four\n", "j0", "\n\n"),
|
||||
(
|
||||
"one keep\nthree four stay\n",
|
||||
"wd9b",
|
||||
"keep\nthree four stay\n",
|
||||
"j02w",
|
||||
"keep\nstay\n",
|
||||
),
|
||||
(
|
||||
"one two keep\nthree four stay\n",
|
||||
"c2wQ",
|
||||
"Qkeep\nthree four stay\n",
|
||||
"j0",
|
||||
"Qkeep\nQstay\n",
|
||||
),
|
||||
(
|
||||
"one two keep\nthree four stay\n",
|
||||
"2wc2bQ",
|
||||
"Qkeep\nthree four stay\n",
|
||||
"j02w",
|
||||
"Qkeep\nQstay\n",
|
||||
),
|
||||
(
|
||||
"one\nthree four\n",
|
||||
"c9wQ",
|
||||
"Q\nthree four\n",
|
||||
"j0",
|
||||
"Q\nQ\n",
|
||||
),
|
||||
(
|
||||
"one keep\nthree four stay\n",
|
||||
"wc9bQ",
|
||||
"Qkeep\nthree four stay\n",
|
||||
"j02w",
|
||||
"Qkeep\nQstay\n",
|
||||
),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor(source);
|
||||
@@ -413,13 +628,25 @@ fn native_multicarets_edit_whole_graphemes_and_yank_matching_suffix_fragments()
|
||||
assert_eq!(editor.context().text, source);
|
||||
assert_eq!(editor.carets(), carets);
|
||||
}
|
||||
for command in ["d2w", "2wd2b"] {
|
||||
for (command, expected) in [
|
||||
("d2w", "keep\nstay\n"),
|
||||
("2wd2b", "keep\nstay\n"),
|
||||
("d9w", "\n\n"),
|
||||
("2wd9b", "keep\nstay\n"),
|
||||
("c2wQ", "Qkeep\nQstay\n"),
|
||||
("2wc2bQ", "Qkeep\nQstay\n"),
|
||||
("c9wQ", "Q\nQ\n"),
|
||||
("2wc9bQ", "Qkeep\nQstay\n"),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let source = "one two keep\nthree four stay\n";
|
||||
let mut editor = fixture.editor(source);
|
||||
editor.handle(Input::Ctrl('e'));
|
||||
keys(&mut editor, command);
|
||||
assert_eq!(editor.context().text, "keep\nstay\n", "{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);
|
||||
@@ -455,6 +682,14 @@ fn remapped_core_edit_actions_keep_counts_pending_input_and_clipboard_semantics(
|
||||
),
|
||||
("core.delete_word_forward", "d", "", "2q", "ghi\nnext\n"),
|
||||
("core.delete_word_backward", "d", "2w", "2q", "ghi\nnext\n"),
|
||||
("core.change_word_forward", "c", "", "2qQ", "Qghi\nnext\n"),
|
||||
(
|
||||
"core.change_word_backward",
|
||||
"c",
|
||||
"2w",
|
||||
"2qQ",
|
||||
"Qghi\nnext\n",
|
||||
),
|
||||
] {
|
||||
let fixture = Fixture::new();
|
||||
let mut editor = fixture.editor("abc def ghi\nnext\n");
|
||||
|
||||
+199
-24
@@ -375,7 +375,10 @@ fn git_working_reads_disk_and_handles_ranges_ignored_files_binary_links_and_odd_
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(reverse.editable_paths.is_empty());
|
||||
assert!(reverse.editable_paths.contains(Path::new("text")));
|
||||
assert!(!reverse.editable_paths.contains(Path::new("added")));
|
||||
assert!(!reverse.editable_paths.contains(Path::new("link")));
|
||||
assert_eq!(changed(&reverse, Path::new("text")).after, ["one", "three"]);
|
||||
assert_eq!(
|
||||
changed(&reverse, Path::new("added")).status,
|
||||
FileStatus::Removed
|
||||
@@ -516,6 +519,46 @@ fn git_remote_refs_and_annotated_tags_are_discovered_before_fetch_and_history_st
|
||||
.success()
|
||||
);
|
||||
assert_eq!(disk_state(&active), state);
|
||||
// An advertised remote alias already available at HEAD is eligible without
|
||||
// importing refs or substituting the local unsaved overlay for pinned bytes.
|
||||
git(
|
||||
&source,
|
||||
&["remote", "add", "origin", bare.to_str().unwrap()],
|
||||
);
|
||||
let source_state = disk_state(&source);
|
||||
let mut checked_out = open(&source);
|
||||
let advertised = checked_out.list_remote("origin").unwrap();
|
||||
let alias = advertised
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.target == remote.target)
|
||||
.unwrap();
|
||||
let pinned = checked_out
|
||||
.compare(
|
||||
20,
|
||||
&revision(&ancestor, RepoKind::Git),
|
||||
alias,
|
||||
ComparisonMode::Snapshots,
|
||||
&[(source.join("file"), "dirty live\n".into())],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(pinned.editable_paths.contains(Path::new("file")));
|
||||
assert_eq!(checked_out.load_file(20, 0).unwrap().after, ["remote tip"]);
|
||||
assert_eq!(disk_state(&source), source_state);
|
||||
git(&source, &["checkout", "--detach", &ancestor]);
|
||||
let readonly = checked_out
|
||||
.refresh_checkout(20, 21, false, &[])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(readonly.editable_paths.is_empty());
|
||||
assert_eq!(checked_out.load_file(21, 0).unwrap().after, ["remote tip"]);
|
||||
git(&source, &["checkout", "--detach", &tip]);
|
||||
let promoted = checked_out
|
||||
.refresh_checkout(21, 22, false, &[])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(promoted.editable_paths, pinned.editable_paths);
|
||||
assert_eq!(checked_out.load_file(22, 0).unwrap().after, ["remote tip"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -707,6 +750,7 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
||||
assert!(branch.before.ends_with(&"0".repeat(40)));
|
||||
assert_eq!(branch.files.len(), 1);
|
||||
assert_eq!(repository.load_file(60, 0).unwrap().after, ["remote tip"]);
|
||||
assert!(branch.editable_paths.is_empty());
|
||||
let comparison =
|
||||
compare_loaded(&mut repository, &remote, &working(RepoKind::Jujutsu), &[]).unwrap();
|
||||
assert_eq!(
|
||||
@@ -760,6 +804,31 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
||||
.success()
|
||||
);
|
||||
assert_eq!(disk_state(&active), state);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_checkout(61, 62, false, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
// Simulate importing and checking out the privately selected endpoint behind
|
||||
// the viewer. Its initially unavailable active-jj identity must be retried.
|
||||
jj(&active, &["git", "fetch", "--remote", "origin"]);
|
||||
jj(&active, &["edit", "--ignore-immutable", &tip]);
|
||||
let checked_out_state = disk_state(&active);
|
||||
let promoted = repository
|
||||
.refresh_checkout(
|
||||
61,
|
||||
62,
|
||||
false,
|
||||
&[(active.join("file"), "dirty live\n".into())],
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(promoted.before, branch.before);
|
||||
assert_eq!(promoted.after, branch.after);
|
||||
assert!(promoted.editable_paths.contains(Path::new("file")));
|
||||
assert_eq!(repository.load_file(62, 0).unwrap().after, ["remote tip"]);
|
||||
assert_eq!(disk_state(&active), checked_out_state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1327,7 +1396,7 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
|
||||
fs::write(root.join("file"), "later disk\n").unwrap();
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(1, 2, &[])
|
||||
.refresh_checkout(1, 2, true, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
@@ -1340,12 +1409,19 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
|
||||
let state = disk_state(&root);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(99, 2, &[])
|
||||
.refresh_checkout(99, 2, true, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_checkout(1, 2, false, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]);
|
||||
let mut refreshed = repository
|
||||
.refresh_working_after(1, 2, &[(root.join("file"), "unsaved\n".into())])
|
||||
.refresh_checkout(1, 2, true, &[(root.join("file"), "unsaved\n".into())])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(refreshed.before, original.before);
|
||||
@@ -1369,7 +1445,7 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
|
||||
assert!(repository.load_file(1, 0).is_err());
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(2, 3, &[])
|
||||
.refresh_checkout(2, 3, true, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
@@ -1377,7 +1453,7 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
|
||||
git(&root, &["checkout", "--detach", &base]);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(2, 3, &[])
|
||||
.refresh_checkout(2, 3, true, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
@@ -1387,7 +1463,7 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
|
||||
git(&root, &["commit", "-m", "unrelated to tip"]);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(2, 3, &[])
|
||||
.refresh_checkout(2, 3, true, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
@@ -1400,7 +1476,7 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
|
||||
git(&root, &["checkout", "--detach", &tip]);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(2, 3, &[])
|
||||
.refresh_checkout(2, 3, true, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
@@ -1431,18 +1507,18 @@ fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure(
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "next"]);
|
||||
cancelled.store(true, Ordering::Release);
|
||||
assert!(repository.refresh_working_after(10, 11, &[]).is_err());
|
||||
assert!(repository.refresh_checkout(10, 11, true, &[]).is_err());
|
||||
cancelled.store(false, Ordering::Release);
|
||||
assert_eq!(repository.load_file(10, 0).unwrap().after, ["frozen after"]);
|
||||
fs::write(root.join("oversized"), vec![b'x'; 17 * 1024 * 1024]).unwrap();
|
||||
assert!(repository.refresh_working_after(10, 11, &[]).is_err());
|
||||
assert!(repository.refresh_checkout(10, 11, true, &[]).is_err());
|
||||
let original = repository.load_file(10, 0).unwrap();
|
||||
assert_eq!(original.before, ["frozen before"]);
|
||||
assert_eq!(original.after, ["frozen after"]);
|
||||
assert!(repository.load_file(11, 0).is_err());
|
||||
fs::remove_file(root.join("oversized")).unwrap();
|
||||
let refreshed = repository
|
||||
.refresh_working_after(10, 11, &[])
|
||||
.refresh_checkout(10, 11, true, &[])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(refreshed.files.len(), 1);
|
||||
@@ -1450,20 +1526,63 @@ fn working_after_refresh_preserves_frozen_working_before_and_sources_on_failure(
|
||||
assert_eq!(file.before, ["frozen before"]);
|
||||
assert_eq!(file.after, ["next checkout"]);
|
||||
let tip = revision(&git(&root, &["rev-parse", "HEAD"]), RepoKind::Git);
|
||||
repository
|
||||
let pinned = repository
|
||||
.compare(12, &base, &tip, ComparisonMode::Snapshots, &[])
|
||||
.unwrap();
|
||||
assert!(pinned.editable_paths.contains(Path::new("file")));
|
||||
fs::write(root.join("file"), "newer checkout\n").unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "newer"]);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(12, 13, &[])
|
||||
.refresh_checkout(99, 13, false, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let readonly = repository
|
||||
.refresh_checkout(12, 13, false, &[(root.join("file"), "dirty live\n".into())])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(readonly.before, pinned.before);
|
||||
assert_eq!(readonly.after, pinned.after);
|
||||
assert!(readonly.editable_paths.is_empty());
|
||||
assert_eq!(readonly.files.len(), pinned.files.len());
|
||||
assert_eq!(readonly.files[0].path, pinned.files[0].path);
|
||||
assert!(repository.load_file(12, 0).is_err());
|
||||
assert_eq!(
|
||||
repository.load_file(12, 0).unwrap().after,
|
||||
repository.load_file(13, 0).unwrap().after,
|
||||
["next checkout"]
|
||||
);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_checkout(13, 14, false, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let RevisionTarget::Commit(tip_id) = &tip.target else {
|
||||
unreachable!()
|
||||
};
|
||||
git(&root, &["checkout", "--detach", tip_id]);
|
||||
let promoted = repository
|
||||
.refresh_checkout(13, 14, false, &[])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(promoted.editable_paths, pinned.editable_paths);
|
||||
fs::write(root.join("file"), "dirty disk\n").unwrap();
|
||||
assert_eq!(
|
||||
repository.load_file(14, 0).unwrap().after,
|
||||
["next checkout"]
|
||||
);
|
||||
cancelled.store(true, Ordering::Release);
|
||||
assert!(repository.refresh_checkout(14, 15, false, &[]).is_err());
|
||||
cancelled.store(false, Ordering::Release);
|
||||
let recovered = repository
|
||||
.refresh_checkout(14, 15, false, &[])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(recovered.editable_paths, pinned.editable_paths);
|
||||
assert_eq!(
|
||||
repository.load_file(15, 0).unwrap().after,
|
||||
["next checkout"]
|
||||
);
|
||||
}
|
||||
@@ -1498,7 +1617,7 @@ fn refresh_materialization_failure_restores_prepared_file_sources() {
|
||||
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());
|
||||
assert!(repository.refresh_checkout(1, 2, true, &[]).is_err());
|
||||
let file = repository.load_file(1, index).unwrap();
|
||||
assert_eq!(file.before, ["frozen source"]);
|
||||
assert!(file.after.is_empty());
|
||||
@@ -1523,7 +1642,7 @@ fn unborn_git_working_after_safely_advances_to_its_first_commit() {
|
||||
.unwrap();
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(1, 2, &[])
|
||||
.refresh_checkout(1, 2, true, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
@@ -1531,7 +1650,7 @@ fn unborn_git_working_after_safely_advances_to_its_first_commit() {
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "initial"]);
|
||||
let comparison = repository
|
||||
.refresh_working_after(1, 2, &[])
|
||||
.refresh_checkout(1, 2, true, &[])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(comparison.files.len(), 1);
|
||||
@@ -1563,7 +1682,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
|
||||
let state = disk_state(&root);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(1, 2, &[])
|
||||
.refresh_checkout(1, 2, true, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
@@ -1574,7 +1693,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
|
||||
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())])
|
||||
.refresh_checkout(1, 2, true, &[(root.join("file"), "unsaved\n".into())])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
for (index, file) in comparison.files.iter_mut().enumerate() {
|
||||
@@ -1594,21 +1713,21 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
|
||||
assert_eq!(disk_state(&root), state);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(2, 3, &[])
|
||||
.refresh_checkout(2, 3, true, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
jj(&root, &["edit", &base]);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(2, 3, &[])
|
||||
.refresh_checkout(2, 3, true, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
jj(&root, &["new", &base]);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(2, 3, &[])
|
||||
.refresh_checkout(2, 3, true, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
@@ -1630,7 +1749,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
|
||||
fs::write(root.join("file"), "new checkout\n").unwrap();
|
||||
jj(&root, &["new"]);
|
||||
let refreshed = repository
|
||||
.refresh_working_after(10, 11, &[])
|
||||
.refresh_checkout(10, 11, true, &[])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(refreshed.files.len(), 1);
|
||||
@@ -1644,10 +1763,66 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
|
||||
jj(&root, &["new"]);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_working_after(12, 13, &[])
|
||||
.refresh_checkout(12, 13, true, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let tip_id = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
let pinned_tip = revision(&tip_id, RepoKind::Jujutsu);
|
||||
let pinned = repository
|
||||
.compare(20, &historical, &pinned_tip, ComparisonMode::Snapshots, &[])
|
||||
.unwrap();
|
||||
assert!(pinned.editable_paths.contains(Path::new("file")));
|
||||
let index = pinned
|
||||
.files
|
||||
.iter()
|
||||
.position(|file| file.path == Path::new("file"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
repository.load_file(20, index).unwrap().after,
|
||||
["new checkout"]
|
||||
);
|
||||
fs::write(root.join("file"), "rewritten checkout\n").unwrap();
|
||||
jj(&root, &["describe", "-m", "same pinned change"]);
|
||||
let rewritten = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
assert_ne!(rewritten, tip_id);
|
||||
assert!(
|
||||
repository
|
||||
.refresh_checkout(20, 21, false, &[])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
repository.load_file(20, index).unwrap().after,
|
||||
["new checkout"]
|
||||
);
|
||||
let same_change = repository
|
||||
.compare(21, &historical, &pinned_tip, ComparisonMode::Snapshots, &[])
|
||||
.unwrap();
|
||||
assert_eq!(same_change.editable_paths, pinned.editable_paths);
|
||||
jj(&root, &["new"]);
|
||||
let readonly = repository
|
||||
.refresh_checkout(21, 22, false, &[(root.join("file"), "dirty live\n".into())])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(readonly.editable_paths.is_empty());
|
||||
assert_eq!(readonly.before, pinned.before);
|
||||
assert_eq!(readonly.after, pinned.after);
|
||||
assert_eq!(readonly.files.len(), pinned.files.len());
|
||||
assert_eq!(
|
||||
repository.load_file(22, index).unwrap().after,
|
||||
["new checkout"]
|
||||
);
|
||||
jj(&root, &["edit", &rewritten]);
|
||||
let promoted = repository
|
||||
.refresh_checkout(22, 23, false, &[])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(promoted.editable_paths, pinned.editable_paths);
|
||||
assert_eq!(
|
||||
repository.load_file(23, index).unwrap().after,
|
||||
["new checkout"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user