Compare commits
23 Commits
b72a3e206f
...
526e243a5f
| Author | SHA1 | Date | |
|---|---|---|---|
| 526e243a5f | |||
| 1fd5f98256 | |||
| d64704d4eb | |||
| 9f1ea04f4d | |||
| 054225686e | |||
| 6b7cfdea20 | |||
| 1b8b669ca0 | |||
| 6eb99a792a | |||
| 406f45c6fb | |||
| 7bc5eab22f | |||
| 684ba46a2c | |||
| 52915ffacf | |||
| 8c6a050171 | |||
| 0d3fbc01f8 | |||
| 4cfd068f1c | |||
| d55b7b65d2 | |||
| 05d62aa4a5 | |||
| 393791ab7e | |||
| 9dd6a666d0 | |||
| f85f84a5d1 | |||
| b733446c8b | |||
| d92140afea | |||
| fc382529c5 |
@@ -28,13 +28,15 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal.
|
|||||||
| Undo/redo in Normal | `u` / `U` (Shift+u) |
|
| Undo/redo in Normal | `u` / `U` (Shift+u) |
|
||||||
| Split panes in Normal | `_` above/below; `\|` left/right; Option/Alt+h/j/k/l changes focus |
|
| Split panes in Normal | `_` above/below; `\|` left/right; Option/Alt+h/j/k/l changes focus |
|
||||||
| Close a pane | `:pane.close`; `:pane.force_close` explicitly discards exclusively displayed unsaved edits |
|
| Close a pane | `:pane.close`; `:pane.force_close` explicitly discards exclusively displayed unsaved edits |
|
||||||
|
| Open/switch tabs in Normal | `:tab.new` opens the current buffer; Ctrl+Option/Alt+h/l switches previous/next |
|
||||||
|
| Close a tab | `:tab.close`; `:tab.force_close` explicitly discards exclusively displayed unsaved edits |
|
||||||
| Delete/change whole lines in Normal | `dd` / `cc`; leading counts work (`3dd`, `2cc`) |
|
| Delete/change whole lines in Normal | `dd` / `cc`; leading counts work (`3dd`, `2cc`) |
|
||||||
| Search in Normal | `/`, type a literal query, Enter; `n`/`N` for next/previous |
|
| Search in Normal | `/`, type a literal query, Enter; `n`/`N` for next/previous |
|
||||||
| Select/copy/cut | `v` characterwise or `V` whole rows, move to extend, then `y`/`d` in Visual |
|
| Select/copy/cut | `v` characterwise or `V` whole rows, move to extend, then `y`/`d` in Visual |
|
||||||
| Paste in Normal | `p`/`P` after/before the caret or row for linewise fragments; system clipboard by default |
|
| Paste in Normal | `p`/`P` after/before the caret or row for linewise fragments; system clipboard by default |
|
||||||
| Command palette | Ctrl+p; type to filter, arrows select, Enter accepts, Esc cancels |
|
| Command palette | Ctrl+p; type to filter, arrows select, Enter accepts, Esc cancels |
|
||||||
| Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` |
|
| Floating/docked explorer in Normal or Visual | Space then `o` / Space then `e` |
|
||||||
| Repository diff in Normal | `:diff`; choose Before then After, Tab changes category, type to search; Working checkout After is editable; `:diff.close` exits |
|
| 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 |
|
||||||
|
|
||||||
Space is the default leader: press its suffix keys in order, not simultaneously. The [editing guide](docs/editing.md) contains the complete key table and behavior reference.
|
Space is the default leader: press its suffix keys in order, not simultaneously. The [editing guide](docs/editing.md) contains the complete key table and behavior reference.
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@ Ruby configuration and plugins run with your account's filesystem/process permis
|
|||||||
|
|
||||||
## Current limits
|
## Current limits
|
||||||
|
|
||||||
- One visible buffer at a time; no splits/tabs or graphical frontend.
|
- Terminal frontend only; no graphical frontend.
|
||||||
- Modal editing, not full Vim compatibility: characterwise and linewise Visual selections and counted `dd`/`cc`, but no block selection, named registers, or general operator–motion grammar. Search is literal, case-sensitive, and single-line.
|
- Modal editing, not full Vim compatibility: characterwise and linewise Visual selections and counted `dd`/`cc`, but no block selection, named registers, or general operator–motion grammar. Search is literal, case-sensitive, and single-line.
|
||||||
- Native opt-in LSP supports diagnostics, go-to-definition, plain-text hover, manual primary-caret completion with snippet arguments, and automatic semantic highlighting—not rename, formatting, or code actions. Completion has no automatic popup or acceptance step; other carets are preserved and translated through the chosen edits. Snippet regex transforms and unresolved variables without defaults are omitted.
|
- Native opt-in LSP supports diagnostics, go-to-definition, plain-text hover, manual primary-caret completion with snippet arguments, and automatic semantic highlighting—not rename, formatting, or code actions. Completion has no automatic popup or acceptance step; other carets are preserved and translated through the chosen edits. Snippet regex transforms and unresolved variables without defaults are omitted.
|
||||||
- System clipboard access supports macOS and Linux. Separate clipboard mode keeps internal editing available on unsupported/headless environments.
|
- System clipboard access supports macOS and Linux. Separate clipboard mode keeps internal editing available on unsupported/headless environments.
|
||||||
|
|||||||
@@ -6,9 +6,12 @@
|
|||||||
- [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.
|
- [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.
|
- 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] diff view: add character-wise change highlight (enabled by default).
|
||||||
- [ ] 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.
|
- [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.
|
||||||
- [ ] 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.
|
- per-tab pane layouts, views, explorers, and comparisons; shared buffers, tab bar, and guarded close/force-close actions.
|
||||||
- this will require tab support.
|
- [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.
|
||||||
- [ ] diff view: change diff view modes; choose between split and unified diff view (split by default).
|
- [ ] diff view: change diff view modes; choose between split and unified diff view (split by default).
|
||||||
- [ ] diff view: for split view, change split orientation (left/right vs above/below) - left/right by default.
|
- [ ] diff view: for split view, change split orientation (left/right vs above/below) - left/right by default.
|
||||||
- [ ] 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: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
|
||||||
@@ -16,3 +19,11 @@
|
|||||||
- 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.
|
- 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).
|
- [ ] 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.
|
- verify that notification-triggered queries return fresh colors in Ghostty through Herdr before removal; retain startup, focus-regain, and notification-driven palette refresh.
|
||||||
|
- [ ] 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.
|
||||||
|
- mount historical source read-only and provide isolated writable scratch directories for caches, indexes, and build output; keep historical navigation targets read-only.
|
||||||
|
- 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.
|
||||||
|
|||||||
+15
-4
@@ -8,7 +8,9 @@ Contributor-facing module boundaries and implementation contracts. Project termi
|
|||||||
|
|
||||||
One editor crate; the workspace also includes its patched Crossterm dependency. Terminal-coordinate types remain outside the editing backend:
|
One editor crate; the workspace also includes its patched Crossterm dependency. Terminal-coordinate types remain outside the editing backend:
|
||||||
|
|
||||||
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, key-sequence/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
- `editor`: owns shared buffer lifecycle/text/saved state/undo history and independent tab/pane layouts, focused views, modes, selections, searches, explorers, and comparisons; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, key-sequence/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||||
|
- `chrome`: owns validated top/bottom bar anchor groups, segment title templates and padding/edge options, builtin defaults, named-value validation, and typed command/search popup anchors. Both bars accept the same segments and layout options. It has no terminal geometry or render callbacks.
|
||||||
|
- `battery`: reads native charge off the input thread, sampling immediately and every 30 seconds through an interruptible worker. macOS CoreFoundation references are type-checked and owned Copy/Create values released; Linux reads system battery sysfs and Windows reads native power status. A one-slot atomic mailbox retains the latest sample; absent/invalid readings clear cached presentation instead of inventing a percentage. Session starts collection only while an unoverridden battery segment is configured.
|
||||||
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
||||||
Virtual file leaves retain their original catalog input index for caller-owned annotations; scanned entries and directories remain unannotated.
|
Virtual file leaves retain their original catalog input index for caller-owned annotations; scanned entries and directories remain unannotated.
|
||||||
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
|
- `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.
|
||||||
@@ -19,7 +21,7 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
|
|||||||
- `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, frontend-independent semantic token kinds/modifiers and completion edit/item DTOs, and versioned asynchronous update contracts. Semantic token and completion updates are native-only, not part of the Ruby wire protocol; it has no LSP protocol types.
|
- `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, frontend-independent semantic token kinds/modifiers and completion edit/item DTOs, and versioned asynchronous update contracts. Semantic token and completion updates are native-only, not part of the Ruby wire protocol; it has no LSP protocol types.
|
||||||
- `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, definition/hover results, completion decoding, and semantic token requests/legend decoding. Its private configuration resolver supplies presets without restricting language IDs.
|
- `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, definition/hover results, completion decoding, and semantic token requests/legend decoding. Its private configuration resolver supplies presets without restricting language IDs.
|
||||||
- `snippet`: owns bounded, frontend-independent template parsing and expansion into plain replacement text and byte-relative tabstops with per-occurrence ancestry. It never edits documents or owns input policy.
|
- `snippet`: owns bounded, frontend-independent template parsing and expansion into plain replacement text and byte-relative tabstops with per-occurrence ancestry. It never edits documents or owns input policy.
|
||||||
- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation/completion, and retires failed plugin ownership. A cancellable diff worker owns repository I/O off the input thread; tagged results cannot reopen closed or superseded selections. Diff focus suppresses asynchronous editing/navigation effects without retiring healthy plugins.
|
- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation/completion, and retires failed plugin ownership. Each tab with a comparison has a cancellable repository worker; inactive tabs keep their workers and receive tagged results without changing visible focus. Closed or superseded comparisons reject stale results. Focused diff text supports native LSP navigation, hover, and diagnostics; read-only buffers still reject asynchronous editing effects, and selectors/file lists are not document-action targets.
|
||||||
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID or immutable diff snapshot/file identity, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy.
|
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID or immutable diff snapshot/file identity, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy.
|
||||||
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
|
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
|
||||||
- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI.
|
- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI.
|
||||||
@@ -35,8 +37,13 @@ Frontends inspect `count()` for the optional pending numeric prefix; parsing, co
|
|||||||
Frontends inspect `sequence_pending()` for the cached display of a pending key sequence; prefix recognition, cancellation, and dispatch remain editor-owned. Symbolic leader routes and explicit key prefixes share the same binding storage and dispatcher; explicit prefixes do not change when the leader is reconfigured.
|
Frontends inspect `sequence_pending()` for the cached display of a pending key sequence; prefix recognition, cancellation, and dispatch remain editor-owned. Symbolic leader routes and explicit key prefixes share the same binding storage and dispatcher; explicit prefixes do not change when the leader is reconfigured.
|
||||||
Frontends inspect `g_pending()` for the built-in Normal/Visual `gt`/`gb` prefix and Normal-only `gf`. Document-edge motions share multi-target motion dispatch and viewport reveal, retaining Visual anchors and selection shape. Markdown following preflights links and headings before buffer/caret mutation; an unloaded target is validated/prepared once, while loaded targets use their current unsaved text. File identity and activation stay editor-owned.
|
Frontends inspect `g_pending()` for the built-in Normal/Visual `gt`/`gb` prefix and Normal-only `gf`. Document-edge motions share multi-target motion dispatch and viewport reveal, retaining Visual anchors and selection shape. Markdown following preflights links and headings before buffer/caret mutation; an unloaded target is validated/prepared once, while loaded targets use their current unsaved text. File identity and activation stay editor-owned.
|
||||||
Frontends inspect `line_operator_pending()` for the optional Normal `d`/`c` prefix. Dedicated `dd`/`cc` actions project counted caret rows through existing linewise selection normalization, clipboard publication, and atomic range editing, without exposing a temporary Visual mode. Clipboard failures restore the original targets before returning. Repeat recipes retain the requested row count rather than the originally clamped range; change recipes share the existing Insert undo group. There is no general operator–motion parser.
|
Frontends inspect `line_operator_pending()` for the optional Normal `d`/`c` prefix. Dedicated `dd`/`cc` actions project counted caret rows through existing linewise selection normalization, clipboard publication, and atomic range editing, without exposing a temporary Visual mode. Clipboard failures restore the original targets before returning. Repeat recipes retain the requested row count rather than the originally clamped range; change recipes share the existing Insert undo group. There is no general operator–motion parser.
|
||||||
Editor-owned jump history is a bounded 100-location `VecDeque` of buffer IDs and grapheme cursors plus a current index, with no copied paths/text. Successful high-level navigation records departure/destination once; traversal uses unrecorded buffer activation and position placement, skips closed/clamped-duplicate locations, and truncates forward entries only on a distinct new jump. Session synchronization permanently invalidates pending LSP position requests after their source buffer/revision/caret/mode is left, so returning through history cannot revive an old definition reply.
|
Editor-owned jump history is a bounded 100-location `VecDeque` of stable tab/pane identities, buffer IDs, grapheme cursors, and optional comparison identity/file/focus/binding metadata, with no copied paths/text. Successful high-level navigation records departure/destination once; traversal resolves a valid destination view before switching buffers, restores comparison binding and exact cursor visibility, and truncates forward entries only on a distinct new jump. Closed or replaced views fall back only to safe existing ordinary panes; history cannot resurrect a closed comparison or replace an unrelated one. Unavailable destinations are skipped without corrupting branch position. Session synchronization permanently invalidates pending LSP position requests after their source buffer/revision/caret/mode is left, so returning through history cannot revive an old definition reply.
|
||||||
Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer.
|
Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer.
|
||||||
|
Frontends inspect `tab_id()` for stable identity, `tab_index()` for display order, `tab_count()`, and `tab_buffer(index)` for borrowed title metadata. `pane_layout()` describes only the active tab; globally unique pane IDs keep inactive tabs' viewports alive. Documents remain unique across all tabs, while independent pane/buffer views are parked and restored without copying text.
|
||||||
|
Frontends borrow `top_bar_config()` and `bottom_bar_config()` for validated ordered left/center/right segment groups and effective segment styles, and `label(name)` for session-wide custom values. The TUI owns terminal-cell widths, full-row center anchoring, clipping, and accent styling. Label effects carry only a name and plain text; they are global presentation updates, not buffer/revision-bound results or render callbacks.
|
||||||
|
Frontends borrow `popup_config()` for independently configured command/search anchors. The TUI resolves each anchor against the chrome-free workspace using the existing bounded popup size; startup configuration does not alter buffer text, modal input, or notification positioning.
|
||||||
|
Frontends borrow `lsp_label()` / `lsp_names()` for the focused buffer's cached ready-server metadata. Initialization-only worker events trigger status redraws independently of diagnostics; session synchronization clears or replaces names when focus changes or a server retires. `notification_visible()` gates persistent operation/diagnostic popups independently of stored message text, so Escape dismissal neither alters text nor collapses carets. Display-only home abbreviation does not change document identity.
|
||||||
|
|
||||||
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). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
|
||||||
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
|
Frontends 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 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.
|
||||||
@@ -54,7 +61,9 @@ Redraws stage Ratatui's buffer diff, cursor placement, and DEC synchronized-upda
|
|||||||
|
|
||||||
`vendor/crossterm` retains Crossterm 0.29.0's upstream source and MIT license, with a small Unix decoder patch exposing `Event::ColorSchemeChanged` for `CSI ? 997 ; 1/2 n`. The report signals a palette refresh, not a choice of hard-coded light/dark colors. Valid fragmented reports and malformed color reports leave following keys intact. Cargo patches both direct and Ratatui dependency paths to this one copy; remove the patch/workspace member once upstream supports these notifications.
|
`vendor/crossterm` retains Crossterm 0.29.0's upstream source and MIT license, with a small Unix decoder patch exposing `Event::ColorSchemeChanged` for `CSI ? 997 ; 1/2 n`. The report signals a palette refresh, not a choice of hard-coded light/dark colors. Valid fragmented reports and malformed color reports leave following keys intact. Cargo patches both direct and Ratatui dependency paths to this one copy; remove the patch/workspace member once upstream supports these notifications.
|
||||||
|
|
||||||
The TUI uses Ratatui constraint-based layout for a flexible buffer viewport, a one-row status bar, and a one-row prompt/message area. Scrolling, clipping, highlights, and cursor placement use the allocated regions, not terminal-wide offsets. Below three rows the status bar is hidden; at one row the buffer remains visible unless an active command/search prompt takes priority. Zero-sized terminals render nothing. Layout and terminal-cell text mapping remain frontend-owned.
|
The TUI allocates a flexible buffer viewport and optional one-row top/bottom bars, with no reserved prompt/message footer. Both bars derive visibility from configured content: empty content takes no row, and tabs-only content is hidden with a single tab in either position. Both render across ordinary, split, diff, and selector views. Ordered left/center/right groups use full-row centering, active-tab collision priority, internal padding, and independent styled edges. Floating Command/Search input owns the hardware cursor; non-focusable operation/diagnostic notifications anchor bottom-right inside the chrome-free workspace and hide obscured document carets. Higher-priority modal overlays suppress them without changing input state. Wheel hit regions clear each frame and consume reports over these popups. Zero-sized terminals render nothing; minimal-height input never reserves another row. Layout and terminal-cell text mapping remain frontend-owned.
|
||||||
|
|
||||||
|
Comparison views reserve no separate metadata header or native-buffer help footer. Revision labels render bottom-left in pane borders (combined on single-pane views), with per-side editability top-right and focus indicated by border styling. Dock and document geometry use the reclaimed workspace rows; conditional loading/error/file notices remain separate from the border metadata.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -78,6 +87,8 @@ macOS uses fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`,
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
Semantic tokens belong only to the first assigned server. Initialization advertises standard token types/modifiers, relative encoding, full/range requests, and server refresh support; deltas, overlapping/multiline tokens, and dynamic registration are not advertised. Each buffer has at most one request in flight; changes/refreshes coalesce into the latest snapshot. Full requests are preferred, with whole-document range fallback. A linear UTF-16 decoder validates raw coordinates and legend indices before outward grapheme rounding; overlong ends truncate at EOL, unknown types and invisible spans fall back to syntax. Both wire frames and decoded spans are bounded to 4 MiB. Snapshot identity rejects stale/renamed/closed replies before decoding. Invalid semantic replies clear the overlay and report an error without retiring diagnostics/navigation; primary retirement clears its overlays, and sibling retirement leaves them intact.
|
Semantic tokens belong only to the first assigned server. Initialization advertises standard token types/modifiers, relative encoding, full/range requests, and server refresh support; deltas, overlapping/multiline tokens, and dynamic registration are not advertised. Each buffer has at most one request in flight; changes/refreshes coalesce into the latest snapshot. Full requests are preferred, with whole-document range fallback. A linear UTF-16 decoder validates raw coordinates and legend indices before outward grapheme rounding; overlong ends truncate at EOL, unknown types and invisible spans fall back to syntax. Both wire frames and decoded spans are bounded to 4 MiB. Snapshot identity rejects stale/renamed/closed replies before decoding. Invalid semantic replies clear the overlay and report an error without retiring diagnostics/navigation; primary retirement clears its overlays, and sibling retirement leaves them intact.
|
||||||
|
|||||||
+75
-6
@@ -44,6 +44,68 @@ Plugin paths resolve relative to the config file, not the shell's working direct
|
|||||||
|
|
||||||
`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_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.
|
||||||
|
|
||||||
|
### Top and bottom bars
|
||||||
|
|
||||||
|
`config.bar.top` and `config.bar.bottom` each accept ordered `left`, `center`, and `right` arrays of segment names. Both bars accept every builtin and custom segment, with the same anchors, title templates, padding, styled edges, and collision handling. Left and right groups anchor to their respective edges; the center group is centered on the whole row, not on the gap remaining between the other groups. Array order is display order within each group. Omitted groups and segment options retain that bar's defaults; an empty array removes that group.
|
||||||
|
|
||||||
|
Use the two setters independently; a combined `config.bar = { ... }` assignment is not supported.
|
||||||
|
|
||||||
|
Defaults put `left: [:tabs]` and `right: [:battery]` in the top bar; the bottom has `left: [:mode, :pending, :buffer, :diagnostics, :path, :selections, :count]` and `right: [:language, :lsp, :position]`; other groups are empty. Set the bars independently, as in this swapped layout with status information above and tabs plus live battery charge below:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
Vamp.configure do |config|
|
||||||
|
config.bar.top = {
|
||||||
|
left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer],
|
||||||
|
center: [], right: [:language, :lsp, :position],
|
||||||
|
mode: { padding: 1, leading_edge: "", trailing_edge: "▓▒░" },
|
||||||
|
position: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" }
|
||||||
|
}
|
||||||
|
config.bar.bottom = {
|
||||||
|
left: [:tabs], center: [], right: [:battery],
|
||||||
|
tabs: {
|
||||||
|
title: "{index} {view}{name}{modified}", padding: 1,
|
||||||
|
leading_edge: "░▒▓", trailing_edge: "▓▒░", first_leading_edge: ""
|
||||||
|
},
|
||||||
|
battery: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Builtins are `:tabs`, `:battery`, `:mode`, `:path`, `:position`, `:language`, `:lsp`, `:diagnostics`, `:selections`, `:count`, `:pending`, and `:buffer`, in either bar. They provide tab titles, battery charge, mode, file path/dirty state, caret position, detected language, ready LSP server names, diagnostics, selection count, numeric prefix, pending key sequence, and buffer identity/count.
|
||||||
|
|
||||||
|
A bar with no visible content consumes no row. For example, `config.bar.top = { left: [], center: [], right: [] }` removes the top bar. A bar whose only visible content is `:tabs` is hidden with a single tab, whether above or below the workspace. Narrow layouts first omit inactive tabs, then optional segments from group ends to avoid overlap; the active tab drops decorations before its title is clipped. Commands and buffer searches use floating input; operation feedback uses a bottom-right popup. Neither reserves a footer row.
|
||||||
|
|
||||||
|
Each segment's `title` is a literal template supporting only `{index}`, `{view}`, `{name}`, `{path}`, and `{modified}`: the one-based tab index, comparison-view marker, filename, file path, and dirty marker (`" [+]"` when dirty, `""` when clean). `{view}` expands to `"Diff · "` when the tab owns a comparison or revision selector, otherwise `""`. Tabs use each tab's view and buffer metadata; other segments use the active context. Tabs default to `"{index} {view}{name}{modified}"`; custom templates without `{view}` retain their output. An empty title on another segment uses its builtin content or named label value. Templates are not Ruby code and never invoke a render callback.
|
||||||
|
|
||||||
|
`:path` and `{path}` abbreviate the actual home-directory prefix to `~/` (`~` for the home directory itself). This is display-only: save paths, buffer identity, and LSP file URIs retain the original path. Sibling directories with similar names are not abbreviated.
|
||||||
|
|
||||||
|
`:lsp` displays only ready servers assigned to the focused buffer, in configured stack order joined with ` + `. Disabled, initializing, failed, or unassigned servers do not appear. Names come from the initialization reply's `serverInfo.name`, falling back to the configured executable basename; no arbitrary named label can make an inactive server appear active.
|
||||||
|
|
||||||
|
`padding` adds spaces inside the segment's styling, on both sides of its title, and accepts integers from `0` through `64` (default `1`). `leading_edge` and `trailing_edge` are separate styled glyph strings, not padding; `""` removes an edge. Tabs default to `"░▒▓"` / `"▓▒░"` edges; mode has only the trailing `"▓▒░"` edge, position and battery only the leading `"░▒▓"` edge, and other segments have none. `first_leading_edge` optionally overrides the first occurrence's leading glyphs: for tabs this is the actual first tab, not the first tab remaining after clipping; other segments have one occurrence. Tabs default this override to `""`, suppressing the first tab's left fade. Unicode widths are measured in terminal cells. Edge strings must be single-line and contain no control characters. Unknown style keys, invalid templates, and wrong types reject configuration.
|
||||||
|
|
||||||
|
Any other segment name is a custom named value, such as `:build_status`. Set initial strings with `config.labels`; an empty value hides the custom segment, including its title override, padding, and edges. Names match `[A-Za-z_][A-Za-z0-9_]*`. Labels are plain text: control characters are replaced rather than interpreted as terminal escapes. The same named value can appear in either bar.
|
||||||
|
|
||||||
|
`:battery` displays `bat N%` from the OS, with an immediate background reading and refreshes every 30 seconds. macOS uses IOKit, Linux uses system battery sysfs entries, and Windows uses `GetSystemPowerStatus`; no shell commands run. Missing, unsupported, or unreadable batteries hide the segment. macOS/Linux currently report the first present internal/system battery; Windows uses the OS's system percentage.
|
||||||
|
|
||||||
|
Move `:battery` to any anchor group or remove it from both bars to stop battery collection. A nonempty `config.labels[:battery]` or plugin value explicitly overrides the native text and pauses collection; clearing that value restores native readings. Other custom live values use [`editor.label(name, text)` or asynchronous plugin updates](plugins.md#named-label-updates). Configuration runs once at startup; there are no render callbacks.
|
||||||
|
|
||||||
|
### Command and search popup anchors
|
||||||
|
|
||||||
|
Configure the two input windows independently:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
Vamp.configure do |config|
|
||||||
|
config.popup.command = { anchor: :top_right }
|
||||||
|
config.popup.search = { anchor: :center }
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Both default to `:bottom_left`. Supported anchors are `:top_left`, `:top_center`, `:top_right`, `:center_left`, `:center`, `:center_right`, `:bottom_left`, `:bottom_center`, and `:bottom_right`. String values and string hash keys are also accepted.
|
||||||
|
|
||||||
|
Anchors refer to the chrome-free workspace, not the whole terminal or focused pane. Center positions use the full workspace width/height. The windows remain bounded to 72 columns by 3 rows, preserving titles, internal padding, cursor clipping, and modal behavior. This setting does not move revision selectors or the operation notification.
|
||||||
|
|
||||||
|
Only the `anchor` option is supported; unknown anchors/options, callbacks, nulls, and wrong types reject configuration. Omitting a window or its anchor keeps its default. Use the two setters separately; a combined `config.popup = { ... }` assignment is not supported.
|
||||||
|
|
||||||
## Actions and modes
|
## Actions and modes
|
||||||
|
|
||||||
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings, canonical Ex names (such as `"quit"`), shorthand aliases (such as `"q"`), and mode strings are not accepted. Only plugin-owned commands use exact string names (`"greet"`, without `:` or arguments) and can only be bound in Normal mode. Interactive Ex commands remain textual. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
|
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings, canonical Ex names (such as `"quit"`), shorthand aliases (such as `"q"`), and mode strings are not accepted. Only plugin-owned commands use exact string names (`"greet"`, without `:` or arguments) and can only be bound in Normal mode. Interactive Ex commands remain textual. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
|
||||||
@@ -57,15 +119,17 @@ For command-capable actions, canonical Ex names derive automatically from the ac
|
|||||||
Key notation follows [Herdr's readable style](https://herdr.dev/docs/configuration/#keybindings). Literal non-control Unicode characters are case-sensitive (`n`, `J`, `+`); named keys and modifiers are case-insensitive:
|
Key notation follows [Herdr's readable style](https://herdr.dev/docs/configuration/#keybindings). Literal non-control Unicode characters are case-sensitive (`n`, `J`, `+`); named keys and modifiers are case-insensitive:
|
||||||
|
|
||||||
- Named keys: `esc`, `enter`, `backspace`, `delete`, `left`, `right`, `up`, `down`, `home`, `end`, `tab`, `space`.
|
- Named keys: `esc`, `enter`, `backspace`, `delete`, `left`, `right`, `up`, `down`, `home`, `end`, `tab`, `space`.
|
||||||
- Modifiers: `ctrl+a` through `ctrl+z`, `alt+a` through `alt+z` (`option+` is an alias for `alt+`), `shift+a` through `shift+z`, and `shift+tab`. `shift+j` and `J` address the same key.
|
- Modifiers: `ctrl+a` through `ctrl+z`, `alt+a` through `alt+z` (`option+` is an alias for `alt+`), `ctrl+alt+a` through `ctrl+alt+z` (either modifier order), `shift+a` through `shift+z`, and `shift+tab`. `shift+j` and `J` address the same key.
|
||||||
- Named punctuation: `minus`, `comma`, `ampersand`, `plus`, `backtick`. Each addresses the same binding as its literal character.
|
- Named punctuation: `minus`, `comma`, `ampersand`, `plus`, `backtick`. Each addresses the same binding as its literal character.
|
||||||
|
|
||||||
`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. A single modifier on an ASCII letter is supported, plus Shift+Tab. Cmd/Super, combined modifiers, other modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
|
`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. ASCII letters support a single modifier or combined Ctrl+Alt/Option; Shift+Tab is also supported. Cmd/Super, other combined modifiers, other modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
|
||||||
|
|
||||||
Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i`/`tab` to `config.core.jump_forward`. Insert-mode Tab inserts literal text outside snippets; inside one it moves to the next argument. Override or disable these keys with the ordinary per-mode binding interface; jump actions also appear in the command palette.
|
Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i`/`tab` to `config.core.jump_forward`. Insert-mode Tab inserts literal text outside snippets; inside one it moves to the next argument. Override or disable these keys with the ordinary per-mode binding interface; jump actions also appear in the command palette.
|
||||||
|
|
||||||
Normal mode uses literal `_` and `|` for above/below and left/right splits, and `alt+h/j/k/l` for directional pane focus. Bind the produced punctuation rather than a physical Shift combination. Close actions have no default binding; for example, `config.bind "leader x", config.core.pane.close`. See [panes and splits](editing.md#panes-and-splits) for shared-buffer and unsaved-change policies.
|
Normal mode uses literal `_` and `|` for above/below and left/right splits, and `alt+h/j/k/l` for directional pane focus. Bind the produced punctuation rather than a physical Shift combination. Close actions have no default binding; for example, `config.bind "leader x", config.core.pane.close`. See [panes and splits](editing.md#panes-and-splits) for shared-buffer and unsaved-change policies.
|
||||||
|
|
||||||
|
Normal mode binds `ctrl+option+h/l` (`ctrl+alt+h/l`) to previous/next tab. Tab actions have no default open/close binding; for example, `config.bind "leader t", config.core.tab.new` and `config.bind "leader x", config.core.tab.close`. See [tabs](editing.md#tabs) for layout preservation and close guards.
|
||||||
|
|
||||||
The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visual; `gf` follows Markdown file links in Normal only. Bind `config.core.document_start`, `config.core.document_end`, or `config.core.follow_link` to another key or sequence to add another route. A configured/disabled `g`, a configured sequence starting with `g`, or choosing `g` as the leader overrides the built-in prefix in that binding mode.
|
The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visual; `gf` follows Markdown file links in Normal only. Bind `config.core.document_start`, `config.core.document_end`, or `config.core.follow_link` to another key or sequence to add another route. A configured/disabled `g`, a configured sequence starting with `g`, or choosing `g` as the leader overrides the built-in prefix in that binding mode.
|
||||||
|
|
||||||
Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `config.core.delete_line` or `config.core.change_line` to another key or sequence for another route. A configured/disabled `d` or `c`, a configured sequence starting with that key, or choosing it as the leader overrides its built-in doubled sequence. These are dedicated line actions, not general operator–motion grammar.
|
Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `config.core.delete_line` or `config.core.change_line` to another key or sequence for another route. A configured/disabled `d` or `c`, a configured sequence starting with that key, or choosing it as the leader overrides its built-in doubled sequence. These are dedicated line actions, not general operator–motion grammar.
|
||||||
@@ -87,6 +151,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con
|
|||||||
| Normal, Insert, Visual, Command, Search | `diff.close` |
|
| Normal, Insert, Visual, Command, Search | `diff.close` |
|
||||||
| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` |
|
| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` |
|
||||||
| Normal | `pane.split_horizontal`, `pane.split_vertical`, `pane.focus_left`, `pane.focus_down`, `pane.focus_up`, `pane.focus_right`, `pane.close`, `pane.force_close` |
|
| Normal | `pane.split_horizontal`, `pane.split_vertical`, `pane.focus_left`, `pane.focus_down`, `pane.focus_up`, `pane.focus_right`, `pane.close`, `pane.force_close` |
|
||||||
|
| Normal | `tab.new`, `tab.previous`, `tab.next`, `tab.close`, `tab.force_close` |
|
||||||
| Normal | `jump_back`, `jump_forward` |
|
| Normal | `jump_back`, `jump_forward` |
|
||||||
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
|
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
|
||||||
| Insert | `lsp.completion`, `snippet.next`, `snippet.previous` |
|
| Insert | `lsp.completion`, `snippet.next`, `snippet.previous` |
|
||||||
@@ -111,7 +176,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con
|
|||||||
|
|
||||||
`core.select` (`v`) and `core.select_linewise` (`V`) select characterwise and whole-row Visual shapes. Switching selectors retains endpoints; pressing the active shape's selector again cancels. Both use `config.modes.visual`; there is no separate linewise binding mode. `core.select_lines` (Visual Ctrl+l) still splits touched rows into Normal carets.
|
`core.select` (`v`) and `core.select_linewise` (`V`) select characterwise and whole-row Visual shapes. Switching selectors retains endpoints; pressing the active shape's selector again cancels. Both use `config.modes.visual`; there is no separate linewise binding mode. `core.select_lines` (Visual Ctrl+l) still splits touched rows into Normal carets.
|
||||||
|
|
||||||
`core.save` saves the current path; `core.save_and_quit` saves the active buffer and quits only if no buffer remains dirty. `core.quit` refuses unsaved changes in any buffer. `core.edit` and `core.buffer` open prefilled Ex prompts for their required path or buffer ID; deletion actions target the current buffer. Configured `core.force_quit`, `core.buffer_force_delete`, and `core.pane.force_close` keys act immediately, without confirmation.
|
`core.save` saves the current path; `core.save_and_quit` saves the active buffer and quits only if no buffer remains dirty. `core.quit` refuses unsaved changes in any buffer. `core.edit` and `core.buffer` open prefilled Ex prompts for their required path or buffer ID; deletion actions target the current buffer. Configured `core.force_quit`, `core.buffer_force_delete`, `core.pane.force_close`, and `core.tab.force_close` keys act immediately, without confirmation.
|
||||||
|
|
||||||
## Binding overrides and failures
|
## Binding overrides and failures
|
||||||
|
|
||||||
@@ -126,7 +191,7 @@ Vamp.configure do |config|
|
|||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
The last valid declaration for a key/mode wins. Invalid keys, incompatible action/mode pairs, ambiguous sequence prefixes, or unknown command names leave that binding unchanged and appear in the status message; other valid bindings still load. An invalid leader key reports a notice and retains Space, while valid bindings still install. Ruby syntax errors, unknown methods, and type errors—including non-string or empty leader values—disable the entire configuration and fall back to defaults. One plugin's load failure does not prevent other plugins loading. Disabling a plugin removes its single-key and sequence bindings, without restoring any default keys they replaced.
|
The last valid declaration for a key/mode wins. Invalid keys, incompatible action/mode pairs, ambiguous sequence prefixes, or unknown command names leave that binding unchanged and appear in the operation notification; other valid bindings still load. An invalid leader key reports a notice and retains Space, while valid bindings still install. Ruby syntax errors, unknown methods, and type errors—including non-string or empty leader values—disable the entire configuration and fall back to defaults. One plugin's load failure does not prevent other plugins loading. Disabling a plugin removes its single-key and sequence bindings, without restoring any default keys they replaced.
|
||||||
|
|
||||||
Bindings are resolved inside the editor module, not the terminal adapter. Bracketed paste remains literal and bypasses keybindings. If configuration disables your keys for leaving Insert/Command/Search/Visual mode or entering commands, restart with `--no-config` to restore defaults.
|
Bindings are resolved inside the editor module, not the terminal adapter. Bracketed paste remains literal and bypasses keybindings. If configuration disables your keys for leaving Insert/Command/Search/Visual mode or entering commands, restart with `--no-config` to restore defaults.
|
||||||
|
|
||||||
@@ -151,7 +216,11 @@ 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; Working checkout After reuses its editable buffer. The catalog contains only changed paths, with A/M/D/R status markers, rather than a disk scan.
|
||||||
|
|
||||||
Ordinary `config.core.file_picker` and `config.core.file_explorer` bindings work inside comparisons. Ctrl+w closes a focused picker/explorer, retaining its query, selection, and expansion state; the panes use the reclaimed width. Ctrl+e has no reserved diff behavior. Close the comparison explicitly with `:diff.close`, its palette action, or the generated action value below. `config.core.diff` remains the opening action and also exposes its immutable `close` child; Escape never closes a comparison.
|
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 also exposes its immutable `close` child.
|
||||||
|
|
||||||
|
`config.core.pane.close` and `config.core.pane.force_close` close a focused **docked** file list instead of its editor pane, without discarding buffers or exiting the comparison. A floating picker does not intercept these actions: normal close still protects underlying dirty buffers, while force-close retains its existing discard semantics. For example, bind `config.bind "ctrl+x", config.core.pane.close`; focused tree/selection overrides and printable filter input retain priority. Palette-originated close actions retain the originating dock target through force confirmation.
|
||||||
|
|
||||||
|
Focused selector/filter input receives printable text before Normal-mode action bindings, so `:` stays query text instead of opening Command input. Explicit tree/selection navigation overrides remain effective. Control-key editor action bindings and Ctrl+p remain available.
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
config.bind "leader d q", config.core.diff.close
|
config.bind "leader d q", config.core.diff.close
|
||||||
@@ -242,7 +311,7 @@ For programmatic bindings, `config.core.lsp` exposes immutable Normal-mode Actio
|
|||||||
|
|
||||||
Interactive LSP commands use dotted names (`:lsp.hover`, `:lsp.definition`, `:lsp.diagnostics`). The existing whitespace separator keeps the whole dotted name separate from arguments. Current LSP commands take no arguments.
|
Interactive LSP commands use dotted names (`:lsp.hover`, `:lsp.definition`, `:lsp.diagnostics`). The existing whitespace separator keeps the whole dotted name separate from arguments. Current LSP commands take no arguments.
|
||||||
|
|
||||||
Diagnostics appear as severity-colored gutter numbers and text underlines; the primary caret's diagnostic appears in the message row. `:lsp.diagnostics` reports the current document's server state. Older versioned diagnostics are discarded; unversioned diagnostics can only be associated with the arrival-time document revision. Editing clears previous annotations immediately. Server startup/transport failures are reported and clear that server's diagnostics; other servers and native editing remain available. There are no automatic restarts.
|
Diagnostics appear as severity-colored gutter numbers and text underlines; the primary caret's diagnostic can also appear in the bottom-right notification popup. `:lsp.diagnostics` reports the current document's server state. Older versioned diagnostics are discarded; unversioned diagnostics can only be associated with the arrival-time document revision. Editing clears previous annotations immediately. Server startup/transport failures are reported and clear that server's diagnostics; other servers and native editing remain available. There are no automatic restarts.
|
||||||
|
|
||||||
Insert mode hides all diagnostic gutter colors, underlines, counts, and caret messages. Incoming results are still stored; leaving Insert reveals only the latest results matching the current text revision. Editing invalidates earlier results, so stale annotations never reappear on exit. Document synchronization stays live for completion and semantic highlighting; this display policy does not stop server-side diagnostic analysis.
|
Insert mode hides all diagnostic gutter colors, underlines, counts, and caret messages. Incoming results are still stored; leaving Insert reveals only the latest results matching the current text revision. Editing invalidates earlier results, so stale annotations never reappear on exit. Document synchronization stays live for completion and semantic highlighting; this display policy does not stop server-side diagnostic analysis.
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ Rust tests live alongside their modules; native LSP integration checks are in [`
|
|||||||
- Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity.
|
- Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity.
|
||||||
- Markdown navigation checks cover inline/reference destinations, escaped/percent-encoded paths, heading markup and duplicates, same-document fragments, dirty target reuse, failed navigation atomicity, primary-only following, and `gf` prefix cancellation/configuration overrides.
|
- Markdown navigation checks cover inline/reference destinations, escaped/percent-encoded paths, heading markup and duplicates, same-document fragments, dirty target reuse, failed navigation atomicity, primary-only following, and `gf` prefix cancellation/configuration overrides.
|
||||||
- Jump-history checks cover cross-buffer/same-document navigation, search and picker recording, counts and repeated visits, bounded history, branch truncation, closed buffers, edited-coordinate clamping, dirty text/undo preservation, Ctrl+i/Tab aliases, and custom bindings. Real LSP fixtures cover definition round-trips and delayed replies after leaving and returning to the same source location.
|
- Jump-history checks cover cross-buffer/same-document navigation, search and picker recording, counts and repeated visits, bounded history, branch truncation, closed buffers, edited-coordinate clamping, dirty text/undo preservation, Ctrl+i/Tab aliases, and custom bindings. Real LSP fixtures cover definition round-trips and delayed replies after leaving and returning to the same source location.
|
||||||
|
- Tab checks cover shared edits/history with independent pane layouts, focused views, explorers and viewports; cross-tab close guards, immutable snapshots, hidden comparison refresh/result routing, and separate repository workers. Rendered checks cover sanitized/dirty titles, active-tab visibility in narrow terminals, overlay row reservation, and exclusion of hidden diff geometry from palette polling and viewport reporting.
|
||||||
- Session and Ruby checks exercise real Ruby execution, command/capability ownership, effect validation, configuration fallback, plugin retirement, crash/timeout recovery, and inherited-pipe cleanup.
|
- Session and Ruby checks exercise real Ruby execution, command/capability ownership, effect validation, configuration fallback, plugin retirement, crash/timeout recovery, and inherited-pipe cleanup.
|
||||||
|
- For chrome changes, exercise anchor/template validation, padding/edge bounds, all builtin/custom segments in either bar, named startup values, and atomic live label batches. Rendering regressions should cover swapped bars across ordinary/split/diff/selector views, full-row center anchoring, terminal-cell glyph widths, empty-content row removal, tabs-only singleton hiding in either position, active-tab preservation, padding inside styled edges, and separate prompt geometry. Labels are session-wide presentation state: test cross-tab/read-only updates and unchanged-value redraw behavior without collecting real battery data or invoking render callbacks.
|
||||||
|
- Battery checks inject samples and refresh gates, use temporary Linux sysfs fixtures, and validate native value types/bounds without depending on the machine's charge or clock. Unit sessions do not automatically read hardware. Live smoke should compare the default indicator with OS telemetry and observe a production refresh; no-battery, explicit overrides, and disabled anchors must not fabricate values.
|
||||||
|
- Status redesign checks cover component-aware home abbreviation without save-path changes; floating command/search submit/cancel, Unicode/tiny/offset clipping and wheel isolation; footer-row reclamation; notification Escape/modal/prefix priority and covered-caret visibility; and ready-server metadata using real framed subprocess fixtures, including stacks, initialization gates, failure, and focus changes.
|
||||||
- Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover raw/alternate-screen/mouse-capture cleanup on entry failure, normal return, I/O errors, and panics during frame construction, including original panic-payload propagation.
|
- Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover raw/alternate-screen/mouse-capture cleanup on entry failure, normal return, I/O errors, and panics during frame construction, including original panic-payload propagation.
|
||||||
- Redraw checks inspect actual Crossterm output during repeated scrolling: supporting terminals receive synchronized frames, terminals ignoring synchronization never paint with a visible hardware cursor, and the final caret remains visible. A protocol-state regression verifies that the terminal is not left in synchronized-output mode while the next frame is computed.
|
- Redraw checks inspect actual Crossterm output during repeated scrolling: supporting terminals receive synchronized frames, terminals ignoring synchronization never paint with a visible hardware cursor, and the final caret remains visible. A protocol-state regression verifies that the terminal is not left in synchronized-output mode while the next frame is computed.
|
||||||
- Wheel checks render document/panel views and cover three-row scrolling, unchanged carets/selections/text, first/last-page clamping, offscreen cursor hiding, no-op/custom-bound navigation reveal, manual persistence through idle/resize/palette updates, and floating/unfocused-docked explorer selection and focus preservation. Queued-input checks verify that the next frame shows ordered wheel steps without crossing a key or resize boundary.
|
- Wheel checks render document/panel views and cover three-row scrolling, unchanged carets/selections/text, first/last-page clamping, offscreen cursor hiding, no-op/custom-bound navigation reveal, manual persistence through idle/resize/palette updates, and floating/unfocused-docked explorer selection and focus preservation. Queued-input checks verify that the next frame shows ordered wheel steps without crossing a key or resize boundary.
|
||||||
@@ -51,6 +55,8 @@ 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.
|
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.
|
||||||
|
|
||||||
|
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.
|
Changed-file explorer checks cover reuse of the compact directory tree, removed/historical paths, file-to-directory transitions, remapped tree/selection bindings, filter/open/focus transitions, right-dock geometry, and wheel routing without edits to the underlying document.
|
||||||
Visibility checks preserve browsing and pane state across Ctrl+e, exclude hidden explorers from focus/wheel routing, and verify full-width review. Status checks preserve catalog associations through sorting, compaction, duplicate inputs, and file/directory collisions, and cover colored A/M/D/R markers, selected/unfocused rows, Tree/List filtering, and sanitized rename paths.
|
Visibility checks preserve browsing and pane state across Ctrl+e, exclude hidden explorers from focus/wheel routing, and verify full-width review. Status checks preserve catalog associations through sorting, compaction, duplicate inputs, and file/directory collisions, and cover colored A/M/D/R markers, selected/unfocused rows, Tree/List filtering, and sanitized rename paths.
|
||||||
Diff styling checks cover independent snapshot language/shebang/injection detection, accepted/stale snapshot identity, real-parser cache freshness/reuse/release, syntax foreground over changed backgrounds, real empty rows versus missing counterparts, scrolling-stable diagonal hatching, and tab/wide/combining/control-character clipping. TestBackend omits wide-glyph continuation cells; terminal smoke verifies their actual styling.
|
Diff styling checks cover independent snapshot language/shebang/injection detection, accepted/stale snapshot identity, real-parser cache freshness/reuse/release, syntax foreground over changed backgrounds, real empty rows versus missing counterparts, scrolling-stable diagonal hatching, and tab/wide/combining/control-character clipping. TestBackend omits wide-glyph continuation cells; terminal smoke verifies their actual styling.
|
||||||
|
|||||||
+52
-12
@@ -17,6 +17,7 @@ Default controls and editing behavior. For custom bindings, see [configuration](
|
|||||||
| Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End |
|
| Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End |
|
||||||
| Normal | `_` splits above/below; `\|` splits left/right; the new pane inherits the current buffer and view |
|
| Normal | `_` splits above/below; `\|` splits left/right; the new pane inherits the current buffer and view |
|
||||||
| Normal | Option/Alt+h/j/k/l focuses the neighboring pane left/down/up/right |
|
| Normal | Option/Alt+h/j/k/l focuses the neighboring pane left/down/up/right |
|
||||||
|
| Normal | Ctrl+Option/Alt+h/l switches to the previous/next tab, wrapping at either end |
|
||||||
| Normal | `w`/`b`/`e` move every caret to next word start, previous/current word start, word end; `W`/`B`/`E` for whitespace-delimited WORDs |
|
| Normal | `w`/`b`/`e` move every caret to next word start, previous/current word start, word end; `W`/`B`/`E` for whitespace-delimited WORDs |
|
||||||
| Normal, Visual | `f<char>` seeks forward on the current line; `F<char>` seeks backward; `2fx` finds the second `x` after each caret |
|
| Normal, Visual | `f<char>` seeks forward on the current line; `F<char>` seeks backward; `2fx` finds the second `x` after each caret |
|
||||||
| Normal | `gf` follows a Markdown file link or heading fragment at the primary caret |
|
| Normal | `gf` follows a Markdown file link or heading fragment at the primary caret |
|
||||||
@@ -141,6 +142,10 @@ Submitting an empty search repeats the last query. Esc cancels without moving th
|
|||||||
|
|
||||||
## Commands, saving, and pasted input
|
## Commands, saving, and pasted input
|
||||||
|
|
||||||
|
`:` opens compact floating command input titled **Command**; `/` opens input titled **Search** for buffer text search. Titles sit at the top-left border, replacing the displayed leading `:` / `/`. Both input fields have one column of padding on each side. Enter submits and Escape cancels using the existing modal rules. Long Unicode input scrolls horizontally without changing buffer text. Both windows default to the workspace's bottom-left edge, directly above the bottom bar when visible; their [anchors can be configured independently](configuration.md#command-and-search-popup-anchors). They temporarily hide operation notifications and never reserve a permanent prompt/footer row. Tiny windows omit borders/titles or padding when needed to keep input usable.
|
||||||
|
|
||||||
|
The latest reported operation result appears in a non-focusable popup at the workspace's bottom-right, just above the bottom bar when visible, with one column of inner padding on each side when space permits. It remains until dismissed or replaced, with no timer. In Normal mode, the first Escape dismisses it without collapsing carets; a subsequent Escape retains ordinary Normal behavior. Existing modal windows, focused explorers/diff selectors, and pending prefixes retain Escape priority. New operation messages reopen the popup; routine successful buffer switches do not duplicate the buffer/path information already shown in the bar.
|
||||||
|
|
||||||
Command-capable core actions derive canonical Ex names by removing `core.`: `core.quit` gives `:quit`, `core.save` gives `:save`, and `core.diff.close` gives `:diff.close`. Native editing and Insert-only completion/snippet actions remain action bindings, not additional Ex commands.
|
Command-capable core actions derive canonical Ex names by removing `core.`: `core.quit` gives `:quit`, `core.save` gives `:save`, and `core.diff.close` gives `:diff.close`. Native editing and Insert-only completion/snippet actions remain action bindings, not additional Ex commands.
|
||||||
|
|
||||||
Commands: `:save [path]`, `:quit`, `:force_quit`, `:save_and_quit [path]`, `:plugins`, and `:plugin-command [arguments]`. Short names remain aliases of these canonical commands, with identical argument and safety rules:
|
Commands: `:save [path]`, `:quit`, `:force_quit`, `:save_and_quit [path]`, `:plugins`, and `:plugin-command [arguments]`. Short names remain aliases of these canonical commands, with identical argument and safety rules:
|
||||||
@@ -185,26 +190,51 @@ Splits can be nested in either direction. The new pane receives focus. Panes sho
|
|||||||
| `:pane.split_horizontal` | `config.core.pane.split_horizontal` | Split above/below (`_` in Normal) |
|
| `:pane.split_horizontal` | `config.core.pane.split_horizontal` | Split above/below (`_` in Normal) |
|
||||||
| `:pane.split_vertical` | `config.core.pane.split_vertical` | Split left/right (`\|` in Normal) |
|
| `:pane.split_vertical` | `config.core.pane.split_vertical` | Split left/right (`\|` in Normal) |
|
||||||
| `:pane.focus_left` / `:pane.focus_down` / `:pane.focus_up` / `:pane.focus_right` | Corresponding `config.core.pane.focus_*` action | Directional focus (Option/Alt+h/j/k/l in Normal) |
|
| `:pane.focus_left` / `:pane.focus_down` / `:pane.focus_up` / `:pane.focus_right` | Corresponding `config.core.pane.focus_*` action | Directional focus (Option/Alt+h/j/k/l in Normal) |
|
||||||
| `:pane.close` | `config.core.pane.close` | Close the focused pane and its exclusively displayed buffer, subject to unsaved-change protection |
|
| `:pane.close` | `config.core.pane.close` | Close a focused docked file list, otherwise close the focused editor pane subject to unsaved-change protection |
|
||||||
| `:pane.force_close` | `config.core.pane.force_close` | Explicitly discard an exclusively displayed buffer's changes and close its pane |
|
| `:pane.force_close` | `config.core.pane.force_close` | Close a focused docked list without discarding data; otherwise explicitly discard exclusively displayed edits and close the editor pane |
|
||||||
|
|
||||||
Closing a pane gives its space to its sibling:
|
Closing a pane gives its space to its sibling:
|
||||||
|
|
||||||
| Situation | Close pane | Force close pane |
|
| Situation | Close pane | Force close pane |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| Focused docked explorer or diff file list | Close only the list and return document focus; retain buffers, edits, tabs, and comparison | Same |
|
||||||
| Clean buffer displayed only here | Close pane and buffer | Same |
|
| Clean buffer displayed only here | Close pane and buffer | Same |
|
||||||
| Dirty buffer displayed only here | Refuse; save or force-close explicitly | Discard edits and close pane and buffer |
|
| Dirty buffer displayed only here | Refuse; save or force-close explicitly | Discard edits and close pane and buffer |
|
||||||
| Buffer also displayed in another pane | Close only this pane; retain the buffer and edits | Same |
|
| Buffer also displayed in another pane or tab | Close only this pane; retain the buffer and edits | Same |
|
||||||
| Last workspace pane | Quit only if every open buffer is clean, including hidden buffers | Quit without saving |
|
| Last pane in a tab, with other tabs open | Close the tab using the same shared-buffer and unsaved-change rules | Same, discarding only exclusively displayed edits |
|
||||||
|
| Last pane in the last tab | Quit only if every open buffer is clean, including hidden buffers | Quit without saving |
|
||||||
|
|
||||||
There is no default close-pane binding. Option must send Alt/Meta in the terminal; on macOS, configure the terminal's Option-key behavior if it instead produces special characters. These bindings do not replace existing Ctrl+j or uppercase motion/edit bindings.
|
There is no default close-pane binding. Option must send Alt/Meta in the terminal; on macOS, configure the terminal's Option-key behavior if it instead produces special characters. These bindings do not replace existing Ctrl+j or uppercase motion/edit bindings.
|
||||||
|
|
||||||
|
A focused docked list is a close-action target even when it is the only visible side panel; closing it never quits or closes its buffer. A visible but unfocused dock does not intercept pane-close. The command palette remembers a dock that had focus when it opened for these two close actions, including force-close confirmation and whitespace edits to that command; cancelling or choosing another action clears that target.
|
||||||
|
|
||||||
|
Floating pickers are not pane-close targets. A resolved close/force-close action passes through to the underlying editor pane/tab with its existing dirty-buffer guards; it is not ignored and does not merely dismiss the picker. A surviving tab-owned floating picker remains open until its ordinary Escape/close behavior or owner closure removes it. Printable bindings remain query text while a filter owns input; control-key or control-prefix bindings can invoke pane-close without altering the query.
|
||||||
|
|
||||||
Pane-focus bindings also work while the ordinary file explorer or picker has focus. Moving to a neighboring pane returns keyboard focus to its document; a floating picker closes, while a docked explorer remains visible. Explicit `tree` or `selection` navigation overrides and disabled keys take precedence over pane-focus bindings.
|
Pane-focus bindings also work while the ordinary file explorer or picker has focus. Moving to a neighboring pane returns keyboard focus to its document; a floating picker closes, while a docked explorer remains visible. Explicit `tree` or `selection` navigation overrides and disabled keys take precedence over pane-focus bindings.
|
||||||
|
|
||||||
Floating windows overlay the entire editor, above all workspace panes. File pickers, revision selectors, hover documentation, and completion lists are not clipped to the focused pane; completion remains anchored to its caret. Docked exploration reserves space at the editor edge before the remaining workspace is split into panes. The changed-file explorer follows the same layout, on the editor's right edge.
|
Floating windows overlay the entire editor, above all workspace panes. File pickers, revision selectors, hover documentation, and completion lists are not clipped to the focused pane; completion remains anchored to its caret. Docked exploration reserves space at the editor edge before the remaining workspace is split into panes. The changed-file explorer follows the same layout, on the editor's right edge.
|
||||||
|
|
||||||
Diff comparisons reuse the split geometry and directional-focus engine. Option/Alt navigation moves between Before, After, and Files first, then to a neighboring workspace pane when there is no internal target in that direction. Splitting a diff text view creates an ordinary sibling showing the same buffer; historical snapshots remain read-only, including after the comparison closes.
|
Diff comparisons reuse the split geometry and directional-focus engine. Option/Alt navigation moves between Before, After, and Files first, then to a neighboring workspace pane when there is no internal target in that direction. Splitting a diff text view creates an ordinary sibling showing the same buffer; historical snapshots remain read-only, including after the comparison closes.
|
||||||
|
|
||||||
|
## Tabs
|
||||||
|
|
||||||
|
Use `:tab.new` to open the current buffer in a new, focused tab with one pane. Each tab preserves its split layout, focused pane, explorer, and comparison. Buffers and undo/redo history remain shared; carets, selections, searches, wrapping, and scroll positions are independent views. Opening a historical diff buffer in a tab keeps it read-only, even after the source comparison closes.
|
||||||
|
|
||||||
|
| Canonical command | Typed action | Behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `:tab.new` | `config.core.tab.new` | Open the current buffer in a new tab |
|
||||||
|
| `:tab.previous` / `:tab.next` | `config.core.tab.previous` / `config.core.tab.next` | Switch tabs, wrapping at either end; Ctrl+Option/Alt+h/l in Normal |
|
||||||
|
| `:tab.close` | `config.core.tab.close` | Close the current tab and all its panes, protecting unsaved changes |
|
||||||
|
| `:tab.force_close` | `config.core.tab.force_close` | Explicitly discard exclusively displayed edits and close the tab |
|
||||||
|
|
||||||
|
By default, multiple tabs show a single top row with numbered filenames and dirty markers, with padding inside each tab's styling. The active tab has an accent-colored background fading into the terminal background through `░▒▓` / `▓▒░`, except the first tab has no left fade; inactive tabs remain unfilled. Narrow terminals keep the active title visible. New/close actions have no default keybinding; for example, `config.bind "leader t", config.core.tab.new`.
|
||||||
|
|
||||||
|
The [top and bottom bars](configuration.md#top-and-bottom-bars) accept the same builtin and custom segments, anchored left, truly centered on the full row, or anchored right. Either bar can show tabs or status information, with independent title templates, internal padding, and styled edge glyphs. Defaults keep tabs and battery above, with home-abbreviated paths on the bottom left and language/ready LSP names/position on the bottom right. Floating input and operation notifications overlay the workspace without reserving a footer. Empty content consumes no row, and a bar whose only visible content is tabs is hidden with a single tab in either position. Custom named values are session-wide and can update through plugins without editing buffers; empty values hide their segments. Both bars share collision handling: narrow layouts first omit inactive tabs, then optional segments from group ends to avoid overlap; the active tab drops decorations before its title is clipped.
|
||||||
|
|
||||||
|
At very small heights, both bars yield to a document row; floating input still accepts text on a one-row terminal. If only one bar row fits while both have content, the top bar receives it.
|
||||||
|
|
||||||
|
Closing a tab refuses dirty buffers whose final displayed owner would disappear, including a comparison's working and return buffers. Buffers displayed in another tab remain open with their edits, even on force-close. Other hidden buffers remain loaded. Closing the last tab quits only if every open buffer is clean; force-close quits without saving.
|
||||||
|
|
||||||
## Buffers
|
## Buffers
|
||||||
|
|
||||||
Each buffer retains its shared text, path, saved state, and undo/redo history. View state is retained separately for each pane and buffer. Caret-following adjusts that view's offsets as needed; mouse-wheel scrolling can temporarily detach it from the caret. Clipboard fragments, bindings, plugin hosts, and plugin retirement remain session-wide. Plugin context and effects refer to the focused pane's active buffer.
|
Each buffer retains its shared text, path, saved state, and undo/redo history. View state is retained separately for each pane and buffer. Caret-following adjusts that view's offsets as needed; mouse-wheel scrolling can temporarily detach it from the caret. Clipboard fragments, bindings, plugin hosts, and plugin retirement remain session-wide. Plugin context and effects refer to the focused pane's active buffer.
|
||||||
@@ -226,6 +256,8 @@ Switching from Insert commits its pending undo group and parks that view in Norm
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
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.
|
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.
|
||||||
|
|
||||||
The dock defaults to the right of the editor workspace. See [configuration preferences](configuration.md#preferences) to choose another side. Status and command/search prompts remain full-width; editor text, gutter, highlights, and cursors use the remaining rectangle. Focused panes prioritize their query on tiny terminals; unfocused docks shrink or hide to preserve editing space.
|
The dock defaults to the right of the editor workspace. See [configuration preferences](configuration.md#preferences) to choose another side. Status and command/search prompts remain full-width; editor text, gutter, highlights, and cursors use the remaining rectangle. Focused panes prioritize their query on tiny terminals; unfocused docks shrink or hide to preserve editing space.
|
||||||
@@ -270,7 +302,9 @@ The floating selector has **Revisions / Bookmarks** tabs for jj, or **Commits /
|
|||||||
| Up / Down, Ctrl+k / Ctrl+j | Previous / next entry |
|
| Up / Down, Ctrl+k / Ctrl+j | Previous / next entry |
|
||||||
| Backspace / Ctrl+u | Remove a query grapheme / clear query |
|
| Backspace / Ctrl+u | Remove a query grapheme / clear query |
|
||||||
| Enter | Choose the entry |
|
| Enter | Choose the entry |
|
||||||
| Esc | Cancel loading; After → return to Before; Before → clear query without closing |
|
| Esc | Close either selector stage, cancel outstanding work, and restore the source buffer without discarding edits |
|
||||||
|
|
||||||
|
Printable input belongs to the focused query, including `:`, `_`, and `|`; Normal-mode command or pane bindings cannot steal it. Explicit selection-navigation bindings still take priority. Use Ctrl+p to select an action, or configure a control-key action binding, when you need an editor action while the selector is focused.
|
||||||
|
|
||||||
The selector initially lists local revisions and known tracking refs **without contacting remotes**. **Load refs from origin** (or another configured remote) in Bookmarks/Branches/Tags lists advertised references, including refs never fetched locally, without advancing the selection stage. Selecting a reference such as `some-bookmark@origin` reuses available content or fetches the missing selected reference into a temporary comparison repository. **Load history from origin** in Revisions/Commits explicitly imports searchable remote history. Network operations use the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout.
|
The selector initially lists local revisions and known tracking refs **without contacting remotes**. **Load refs from origin** (or another configured remote) in Bookmarks/Branches/Tags lists advertised references, including refs never fetched locally, without advancing the selection stage. Selecting a reference such as `some-bookmark@origin` reuses available content or fetches the missing selected reference into a temporary comparison repository. **Load history from origin** in Revisions/Commits explicitly imports searchable remote history. Network operations use the repository's existing credentials; errors stay in the selector. They do not update active refs, snapshot jj's working copy, or change the checkout.
|
||||||
|
|
||||||
@@ -278,9 +312,15 @@ Comparison discovery builds the changed-path/status catalog first. File text, no
|
|||||||
|
|
||||||
The changed-file explorer is **docked on the right**. Modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain the split view. Before and historical/remote After snapshots are read-only. Selecting **Working checkout** as After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. 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.
|
The changed-file explorer is **docked on the right**. Modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain the split view. Before and historical/remote After snapshots are read-only. Selecting **Working checkout** as After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. 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.
|
||||||
|
|
||||||
When a diff occupies the sole workspace pane, its top bar spans the full editor width. The right-docked changed-file explorer starts below that bar rather than covering the header; closing the dock preserves the full-width bar.
|
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.
|
||||||
|
|
||||||
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 remain read-only until closed. Explicit native navigation from the comparison pane to an ordinary buffer leaves the comparison at that destination.
|
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.
|
||||||
|
|
||||||
|
A definition whose source range **overlaps an actual added, removed, or replaced line** stays in the same comparison and selects the correct file and side, loading file details on demand. For a location link, the symbol's full target range determines overlap and its selection range determines the caret: an unchanged declaration with a changed body still belongs in the comparison. Source coordinates belong to that side, including old paths for renames; navigation reveals the exact selection row, even when previously omitted, never the nearest excerpt. A wholly unchanged target opens a **new ordinary tab**, even when it is visible context or omitted text in a changed file. Files outside the comparison's changes also open in a new tab. The original comparison, pane layout, source caret, excerpts, and dirty buffers remain intact. Loaded targets share their existing text and undo history rather than reloading disk; references to snapshot URIs retain the historical text and read-only identity in the new tab, even after the comparison closes. Unreadable files, invalid UTF-8, invalid source ranges, and unsupported remote URIs do not create a tab or disturb the source.
|
||||||
|
|
||||||
Use the ordinary **file-picker/file-explorer actions and bindings**: Space `o` opens a floating picker, Space `e` opens/focuses the docked explorer, and Ctrl+w closes the focused picker/explorer and reclaims its width. Remapped bindings work too; Ctrl+e is no longer reserved by the diff. The changed-path catalog retains A/M/D/R markers and the normal compact tree, filtering, folder expansion, and configurable `tree`/`selection` navigation. It includes removed and historical paths without scanning the working directory. Enter toggles a folder or opens the selected comparison file and focuses its first visible text pane: After for added files, otherwise Before. While the explorer is hidden, Tab/Shift+Tab cycles between visible text panes only; a single-pane view keeps its focus.
|
Use the ordinary **file-picker/file-explorer actions and bindings**: Space `o` opens a floating picker, Space `e` opens/focuses the docked explorer, and Ctrl+w closes the focused picker/explorer and reclaims its width. Remapped bindings work too; Ctrl+e is no longer reserved by the diff. The changed-path catalog retains A/M/D/R markers and the normal compact tree, filtering, folder expansion, and configurable `tree`/`selection` navigation. It includes removed and historical paths without scanning the working directory. Enter toggles a folder or opens the selected comparison file and focuses its first visible text pane: After for added files, otherwise Before. While the explorer is hidden, Tab/Shift+Tab cycles between visible text panes only; a single-pane view keeps its focus.
|
||||||
|
|
||||||
@@ -292,7 +332,7 @@ In **Normal mode** with a text pane focused, **`[` expands above** and **`]` exp
|
|||||||
|
|
||||||
Live typing highlights only the displayed source rows while retaining each snapshot's original syntax tree for multiline and injected-language context. Line-width caches and unchanged hunk layouts avoid repeated full-file work; edits that change line alignment or revert changes recompute the diff. Already-queued printable Insert keys share a redraw, but every key still passes through normal bindings, snippet handling, and undo history. Mode changes and other terminal events retain their order.
|
Live typing highlights only the displayed source rows while retaining each snapshot's original syntax tree for multiline and injected-language context. Line-width caches and unchanged hunk layouts avoid repeated full-file work; edits that change line alignment or revert changes recompute the diff. Already-queued printable Insert keys share a redraw, but every key still passes through normal bindings, snippet handling, and undo history. Mode changes and other terminal events retain their order.
|
||||||
|
|
||||||
Text-pane navigation stays within the shown source lines. At an excerpt boundary, Up/Down or `j`/`k` jumps directly to the next/previous excerpt, and counts count visible lines rather than omitted lines. This also applies to remapped movement keys, Visual-mode movement, and editable After's Insert-mode arrows. Word motions, `gt`/`gb`, search, and occurrence selection use the visible excerpts; `gt`/`gb` target their first/last source line. Hidden short lines do not clamp the caret column. The shared vertical viewport and each pane's horizontal offset stay fixed when movement reverses, scrolling only when the caret crosses a visible edge, as in a normal buffer. Moving or restoring a caret never reveals omitted context: only Normal-mode `[`/`]` expands existing excerpts. Edits still recompute changed regions and retain previously expanded context.
|
Text-pane navigation stays within the shown source lines. At an excerpt boundary, Up/Down or `j`/`k` jumps directly to the next/previous excerpt, and counts count visible lines rather than omitted lines. This also applies to remapped movement keys, Visual-mode movement, and editable After's Insert-mode arrows. Word motions, `gt`/`gb`, search, and occurrence selection use the visible excerpts; `gt`/`gb` target their first/last source line. Hidden short lines do not clamp the caret column. The shared vertical viewport and each pane's horizontal offset stay fixed when movement reverses, scrolling only when the caret crosses a visible edge, as in a normal buffer. Ordinary motions, search, and caret restoration do not reveal omitted context; Normal-mode `[`/`]` expands existing excerpts. LSP definition routing can additionally reveal the exact selection of a symbol whose range overlaps changes. Edits still recompute changed regions and retain previously expanded context.
|
||||||
|
|
||||||
Configure these counts in Ruby with `config.diff_context_lines = 10` (nonnegative; `0` shows changes only) and `config.diff_expand_lines = 15` (positive).
|
Configure these counts in Ruby with `config.diff_context_lines = 10` (nonnegative; `0` shows changes only) and `config.diff_expand_lines = 15` (positive).
|
||||||
|
|
||||||
@@ -306,7 +346,7 @@ Diff backgrounds blend the terminal's default background with its red/green pale
|
|||||||
|
|
||||||
Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched.
|
Missing counterparts use adjacent muted `╱` diagonals (U+2571, box drawings light diagonal) on the normal terminal background, one per column. Unlike the text slash `/`, this glyph is designed to reach cell corners so diagonals can join end to end across successive rows. Pixel-perfect joins depend on the terminal's font and line-spacing rendering. Actual empty source lines retain their normal/change background and are not hatched.
|
||||||
|
|
||||||
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. Escape cancels modes, prompts, or filtering, or returns picker focus to the last bound text pane; **it never closes the diff**. Use **`:diff.close`**, find `diff.close` in the palette, or bind **`config.core.diff.close`** to leave the comparison explicitly. The dotted command is intentional; there is no `:diffclose` alias.
|
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.
|
||||||
|
|
||||||
Both diff panes apply available semantic tokens from their own buffer, independently of keyboard focus. Historical snapshots never borrow the working buffer's tokens; live After tokens must match the displayed text revision. Missing or invalidated tokens leave Tree-sitter syntax colors intact. Semantic colors and modifiers preserve the change backgrounds.
|
Both diff panes apply available semantic tokens from their own buffer, independently of keyboard focus. Historical snapshots never borrow the working buffer's tokens; live After tokens must match the displayed text revision. Missing or invalidated tokens leave Tree-sitter syntax colors intact. Semantic colors and modifiers preserve the change backgrounds.
|
||||||
|
|
||||||
@@ -328,11 +368,11 @@ Space begins a leader sequence in Normal/Visual by default. Explicit sequences s
|
|||||||
|
|
||||||
## Jump history
|
## Jump history
|
||||||
|
|
||||||
Normal Ctrl+o moves back through previous locations; Ctrl+i moves forward. Traditional terminals encode Ctrl+i as Tab, so Normal Tab performs the same action while Insert Tab inserts text outside snippets. Counts work in either direction, such as `3` followed by Ctrl+o, and stop at the available history.
|
Normal Ctrl+o moves back through previous locations; Ctrl+i moves forward. Traditional terminals encode Ctrl+i as Tab, so Normal Tab performs the same action outside comparisons while Insert Tab inserts text outside snippets. In a comparison, Ctrl+i/Tab retains its diff-side focus behavior; use the **Jump forward** palette action or another configured key such as `config.bind "ctrl+y", config.core.jump_forward`. Counts work in either direction, such as `3` followed by Ctrl+o, and stop at the available history.
|
||||||
|
|
||||||
The session keeps up to 100 primary-caret locations across buffers. Successful Markdown links, accepted LSP definitions, file-picker opens, `:edit`, `:buffer`, `:bnext`/`:bprev`, and search `/`/`n`/`N` jumps record locations. Ordinary cursor/word motion, edits, failed navigation, and jumps that do not change location do not add entries. Traversal itself does not record another jump.
|
The session keeps up to 100 primary-caret locations with their tab, pane, and comparison context, not just buffer coordinates. Successful Markdown links, accepted LSP definitions, file-picker opens, `:edit`, `:buffer`, `:bnext`/`:bprev`, and search `/`/`n`/`N` jumps record locations. Back/forward restores the original view, including its comparison file and side; an external LSP definition round-trip does not replace the comparison with an ordinary buffer. Identical buffer/cursor positions in different views remain distinct. Ordinary cursor/word motion, edits, failed navigation, manual tab/pane selection, and jumps that do not change location do not add entries. Traversal itself does not record another jump.
|
||||||
|
|
||||||
Moving away from a visited point before traversing saves the actual departure position for the return trip. A successful new jump after going back discards the forward branch; failed/no-op jumps preserve it. Closed buffers are skipped rather than reopened. Stored row/grapheme coordinates clamp to the current text after edits; text, dirty state, undo/redo, and file contents are unchanged by traversal. History navigation returns to Normal with one primary caret.
|
Moving away from a visited point before traversing saves the actual departure position for the return trip. A successful new jump after going back discards the forward branch; failed/no-op jumps preserve it. Closed buffers are skipped rather than reopened. An unavailable or replaced comparison is never resurrected or overwritten: history uses a safe existing ordinary pane for an available buffer, retaining snapshot read-only identity, or skips the destination if none exists. It never creates a replacement tab. Stored row/grapheme coordinates clamp to current text after edits; text, dirty state, undo/redo, and file contents remain unchanged. History navigation returns to Normal with one primary caret.
|
||||||
|
|
||||||
The typed actions are `config.core.jump_back` and `config.core.jump_forward`; custom bindings can use any supported single key or leader sequence. History is in memory for this session only.
|
The typed actions are `config.core.jump_back` and `config.core.jump_forward`; custom bindings can use any supported single key or leader sequence. History is in memory for this session only.
|
||||||
|
|
||||||
|
|||||||
+27
-3
@@ -22,7 +22,7 @@ Callbacks receive an immutable primary-only context snapshot: `editor.text`, `ed
|
|||||||
|
|
||||||
The Ruby host and examples use [YARD types](https://solargraph.org/guides/yard) for Solargraph navigation and hover. `Vamp.command` declares typed block arguments (`Vamp::Editor`, `String`); the example also annotates them explicitly. Context readers and configuration methods are annotated in [`src/ruby_config.rb`](../src/ruby_config.rb); asynchronous callback/effect contracts live in [`src/ruby_plugin.rb`](../src/ruby_plugin.rb). These comments do not enforce types at runtime; existing boundary validation still does.
|
The Ruby host and examples use [YARD types](https://solargraph.org/guides/yard) for Solargraph navigation and hover. `Vamp.command` declares typed block arguments (`Vamp::Editor`, `String`); the example also annotates them explicitly. Context readers and configuration methods are annotated in [`src/ruby_config.rb`](../src/ruby_config.rb); asynchronous callback/effect contracts live in [`src/ruby_plugin.rb`](../src/ruby_plugin.rb). These comments do not enforce types at runtime; existing boundary validation still does.
|
||||||
|
|
||||||
`editor.insert(text)`, `editor.replace(text)`, and `editor.message(text)` queue effects. The context remains the pre-command snapshot even after queuing an effect. All effects are validated before the backend changes anything; buffer changes from one successful invocation form one undo action. Exceptions, process exits, timeouts, malformed replies, and invalid effects disable that plugin and discard its effects. Other plugins and built-ins remain available. Plugin `puts`, explicit `STDOUT.write`, and stderr cannot overwrite the TUI; diagnostic output is discarded, while exceptions appear in the status message.
|
`editor.insert(text)`, `editor.replace(text)`, `editor.message(text)`, and `editor.label(name, text)` queue effects. The context remains the pre-command snapshot even after queuing an effect. All effects are validated before the backend changes anything; buffer changes from one successful invocation form one undo action. Exceptions, process exits, timeouts, malformed replies, and invalid effects disable that plugin and discard its effects. Other plugins and built-ins remain available. Plugin `puts`, explicit `STDOUT.write`, and stderr cannot overwrite the TUI; diagnostic output is discarded, while exceptions appear in the status message.
|
||||||
|
|
||||||
This is crash isolation, **not a security sandbox**: Ruby configuration and plugins have your user account's filesystem/process permissions. Load only trusted scripts. On Unix, hosts run in their own process groups and their groups are killed on shutdown.
|
This is crash isolation, **not a security sandbox**: Ruby configuration and plugins have your user account's filesystem/process permissions. Load only trusted scripts. On Unix, hosts run in their own process groups and their groups are killed on shutdown.
|
||||||
|
|
||||||
@@ -40,15 +40,39 @@ A feature declares all capabilities it needs. Registration is atomic: if any req
|
|||||||
|
|
||||||
`Vamp.on_document { |event| ... }` receives a deeply frozen hash with `"event"` (`"open"`, `"change"`, `"save"`, or `"close"`) and `"document"` (`"id"`, `"revision"`, `"path"`, `"root"`, `"text"`). Already-open documents are delivered at registration; changes include hidden-buffer edits, undo/redo, and plugin edits. Save-as sends close/open for the changed path. `Vamp.on_shutdown { ... }` supplies a shutdown hook. Callbacks must enqueue slow work rather than blocking host input.
|
`Vamp.on_document { |event| ... }` receives a deeply frozen hash with `"event"` (`"open"`, `"change"`, `"save"`, or `"close"`) and `"document"` (`"id"`, `"revision"`, `"path"`, `"root"`, `"text"`). Already-open documents are delivered at registration; changes include hidden-buffer edits, undo/redo, and plugin edits. Save-as sends close/open for the changed path. `Vamp.on_shutdown { ... }` supplies a shutdown hook. Callbacks must enqueue slow work rather than blocking host input.
|
||||||
|
|
||||||
Worker threads call `Vamp.publish(effects, request_id: nil)`. Background effects are `message`, revision-tagged `diagnostics`, or request-correlated `navigate`/`hover`; they cannot edit text. Diagnostics carry `buffer_id`, `revision`, and `items`, each with end-exclusive `start`/`end` positions, `severity` (`error`, `warning`, `information`, `hint`), and `message`. Positions are **zero-based grapheme coordinates**, not bytes, terminal cells, or UTF-16. Empty item lists clear the owner's annotations.
|
Worker threads call `Vamp.publish(effects, request_id: nil)`. Background effects are `message`, `label`, revision-tagged `diagnostics`, or request-correlated `navigate`/`hover`; they cannot edit text. Diagnostics carry `buffer_id`, `revision`, and `items`, each with end-exclusive `start`/`end` positions, `severity` (`error`, `warning`, `information`, `hint`), and `message`. Positions are **zero-based grapheme coordinates**, not bytes, terminal cells, or UTF-16. Empty item lists clear the owner's annotations.
|
||||||
|
|
||||||
Navigation carries the source `buffer_id`/`revision`, destination `path`/`row`/`column`, and the invocation's `editor.request_id` passed to `publish`. Only the latest still-current source request may move the primary. Closed/stale revisions are ignored; malformed current updates retire their owner. Failed navigation reports an error without discarding buffer edits. Hosts use bounded queues/frames; protocol failure or host exit retires commands, bindings, pending requests, and owned diagnostics.
|
Navigation carries the source `buffer_id`/`revision`, destination `path`/`row`/`column`, and the invocation's `editor.request_id` passed to `publish`. Only the latest still-current source request may move the primary. Closed/stale revisions are ignored; malformed current updates retire their owner. Failed navigation reports an error without discarding buffer edits. Hosts use bounded queues/frames; protocol failure or host exit retires commands, bindings, pending requests, and owned diagnostics.
|
||||||
|
|
||||||
`hover` carries source `buffer_id`/`revision` and multiline `text`, with the invocation's `editor.request_id` passed to `publish`. Only applicable current requests in Normal mode can show a popup; stale results and replies while another modal list is focused are ignored. Text validates before a batch changes state; empty text clears the popup, and owner retirement dismisses its content.
|
`hover` carries source `buffer_id`/`revision` and multiline `text`, with the invocation's `editor.request_id` passed to `publish`. Only applicable current requests in Normal mode can show a popup; stale results and replies while another modal list is focused are ignored. Text validates before a batch changes state; empty text clears the popup, and owner retirement dismisses its content.
|
||||||
|
|
||||||
|
## Named label updates
|
||||||
|
|
||||||
|
`editor.label(name, text)` sets a session-wide named value initialized by `config.labels`. Place that name in a [top or bottom bar anchor group](configuration.md#top-and-bottom-bars) to display it. This presentation effect does not edit text, move carets, change undo history, or dismiss completion. It also works in read-only diff views.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
Vamp.plugin "build_label"
|
||||||
|
|
||||||
|
# @param editor [Vamp::Editor]
|
||||||
|
# @param args [String]
|
||||||
|
Vamp.command "build_label" do |editor, args|
|
||||||
|
editor.label("build_status", args)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Place `:build_status` in a bar and load this plugin with `config.plugin`, then use `:build_label passed`; `:build_label` clears the value and hides its segment. For asynchronous data, an `async: true` plugin's worker publishes a named value without a request ID:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
Vamp.publish([{ type: "label", name: "build_status", text: "passed" }])
|
||||||
|
```
|
||||||
|
|
||||||
|
Collect custom data and choose its refresh interval in the plugin; the builtin `:battery` already refreshes from native OS data. There are no render callbacks. Stop worker threads in `Vamp.on_shutdown`. Changed values redraw while idle and persist across tabs, without depending on the originating buffer or revision. Publishing an unchanged value does not request a redraw. The last value remains until overwritten or cleared, including after plugin retirement.
|
||||||
|
|
||||||
|
Names match `[A-Za-z_][A-Za-z0-9_]*` and text must be a string. Control characters become replacement glyphs; an empty string clears the value. Updates share the existing 4 MiB protocol/text bounds; a rejected batch cannot partially change labels or other state.
|
||||||
|
|
||||||
## Messaging
|
## Messaging
|
||||||
|
|
||||||
Rust and Ruby exchange UTF-8, newline-delimited JSON objects with `version: 1`. Startup returns `manifest` (name, required capabilities, commands, async opt-in) or `config` (plugin paths/options and bindings). `invoke` requests carry an ID, command, arguments, and context; `result` replies carry the matching ID and typed text effects (`insert`, `replace`, `message`). Async hosts also receive `document`/`shutdown` and send `update` frames with optional request IDs and typed presentation/navigation effects. Errors use `error` frames. Unknown fields, wrong versions, and mismatched replies are rejected. Frames and aggregate effect text are bounded to 4 MiB.
|
Rust and Ruby exchange UTF-8, newline-delimited JSON objects with `version: 1`. Startup returns `manifest` (name, required capabilities, commands, async opt-in) or `config` (plugin paths/options, bindings, `top_bar` and `bottom_bar` chrome maps, `popup` anchor options, and named labels). `invoke` requests carry an ID, command, arguments, and context; `result` replies carry the matching ID and typed effects (`insert`, `replace`, `message`, `label`). A `label` effect carries `name` and `text` strings. Async hosts also receive `document`/`shutdown` and send `update` frames with optional request IDs and typed presentation/navigation effects. Errors use `error` frames. Unknown fields, wrong versions, and mismatched replies are rejected. Frames and aggregate effect text are bounded to 4 MiB.
|
||||||
|
|
||||||
Before loading a script, Rust supplies a bounded startup JSON envelope containing `core_actions` and `options`. The action catalog initializes the Ruby interface; only the separate user-options object is exposed through `Vamp.options`, including when user options contain a key named `core_actions`.
|
Before loading a script, Rust supplies a bounded startup JSON envelope containing `core_actions` and `options`. The action catalog initializes the Ruby interface; only the separate user-options object is exposed through `Vamp.options`, including when user options contain a key named `core_actions`.
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,23 @@ Vamp.configure do |config|
|
|||||||
config.file_explorer_side = "right"
|
config.file_explorer_side = "right"
|
||||||
config.file_explorer_view = "tree"
|
config.file_explorer_view = "tree"
|
||||||
config.file_picker_view = "tree"
|
config.file_picker_view = "tree"
|
||||||
|
# config.popup.command = { anchor: :bottom_left }
|
||||||
|
# config.popup.search = { anchor: :center }
|
||||||
|
# Status above, tabs and live battery below; either bar accepts all segments.
|
||||||
|
config.bar.top = {
|
||||||
|
left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer],
|
||||||
|
center: [], right: [:language, :lsp, :position],
|
||||||
|
mode: { padding: 1, leading_edge: "", trailing_edge: "▓▒░" },
|
||||||
|
position: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" }
|
||||||
|
}
|
||||||
|
config.bar.bottom = {
|
||||||
|
left: [:tabs], center: [], right: [:battery],
|
||||||
|
tabs: {
|
||||||
|
title: "{index} {view}{name}{modified}", padding: 1,
|
||||||
|
leading_edge: "░▒▓", trailing_edge: "▓▒░", first_leading_edge: ""
|
||||||
|
},
|
||||||
|
battery: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" }
|
||||||
|
}
|
||||||
config.bind "ctrl+j", config.core.down, mode: config.modes.selection
|
config.bind "ctrl+j", config.core.down, mode: config.modes.selection
|
||||||
config.bind "ctrl+k", config.core.up, mode: config.modes.selection
|
config.bind "ctrl+k", config.core.up, mode: config.modes.selection
|
||||||
config.plugin "plugins/greeting.rb"
|
config.plugin "plugins/greeting.rb"
|
||||||
|
|||||||
+542
@@ -0,0 +1,542 @@
|
|||||||
|
use std::{
|
||||||
|
io,
|
||||||
|
sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicU8, Ordering},
|
||||||
|
mpsc::{self, Receiver, Sender},
|
||||||
|
},
|
||||||
|
thread::{self, JoinHandle},
|
||||||
|
};
|
||||||
|
#[cfg(not(test))]
|
||||||
|
use std::{sync::mpsc::RecvTimeoutError, time::Duration};
|
||||||
|
|
||||||
|
pub(crate) struct Battery {
|
||||||
|
results: Arc<AtomicU8>,
|
||||||
|
wake: Option<Sender<()>>,
|
||||||
|
thread: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Battery {
|
||||||
|
#[cfg(not(test))]
|
||||||
|
pub(crate) fn start() -> io::Result<Self> {
|
||||||
|
Self::start_with(read_charge, |wake| {
|
||||||
|
matches!(
|
||||||
|
wake.recv_timeout(Duration::from_secs(30)),
|
||||||
|
Ok(()) | Err(RecvTimeoutError::Timeout)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_with(
|
||||||
|
mut read: impl FnMut() -> Option<u8> + Send + 'static,
|
||||||
|
mut wait: impl FnMut(&Receiver<()>) -> bool + Send + 'static,
|
||||||
|
) -> io::Result<Self> {
|
||||||
|
let results = Arc::new(AtomicU8::new(0));
|
||||||
|
let latest = Arc::clone(&results);
|
||||||
|
let (wake, requests) = mpsc::channel();
|
||||||
|
let thread = thread::Builder::new()
|
||||||
|
.name("vamp-battery".into())
|
||||||
|
.spawn(move || {
|
||||||
|
loop {
|
||||||
|
let charge = read().filter(|charge| *charge <= 100);
|
||||||
|
// A one-slot latest-result mailbox: 0 = no new sample,
|
||||||
|
// 1 = no battery, 2..=102 = charge. Publishing never blocks.
|
||||||
|
latest.store(charge.map_or(1, |charge| charge + 2), Ordering::Relaxed);
|
||||||
|
if !wait(&requests) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(Self {
|
||||||
|
results,
|
||||||
|
wake: Some(wake),
|
||||||
|
thread: Some(thread),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn poll(&mut self) -> Option<Option<u8>> {
|
||||||
|
match self.results.swap(0, Ordering::Relaxed) {
|
||||||
|
0 => None,
|
||||||
|
1 => Some(None),
|
||||||
|
charge => Some(Some(charge - 2)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Battery {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Disconnecting interrupts recv_timeout instead of waiting for its deadline.
|
||||||
|
self.wake.take();
|
||||||
|
if let Some(thread) = self.thread.take() {
|
||||||
|
let _ = thread.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
pub(crate) fn read_charge() -> Option<u8> {
|
||||||
|
// ponytail: first internal battery on macOS/Linux; aggregate compatible capacities for multi-battery systems.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
return macos::read_charge();
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
return linux::read_charge(std::path::Path::new("/sys/class/power_supply"));
|
||||||
|
#[cfg(windows)]
|
||||||
|
return windows::read_charge();
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux", test))]
|
||||||
|
fn percentage(current: u64, full: u64) -> Option<u8> {
|
||||||
|
if full == 0 || current > full {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(((current as u128 * 100) / full as u128) as u8)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod macos {
|
||||||
|
use std::ffi::{CStr, c_char, c_void};
|
||||||
|
|
||||||
|
type Object = *const c_void;
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
#[link(name = "IOKit", kind = "framework")]
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn IOPSCopyPowerSourcesInfo() -> Object;
|
||||||
|
fn IOPSCopyPowerSourcesList(info: Object) -> Object;
|
||||||
|
fn IOPSGetPowerSourceDescription(info: Object, source: Object) -> Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[link(name = "CoreFoundation", kind = "framework")]
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn CFRelease(object: Object);
|
||||||
|
fn CFGetTypeID(object: Object) -> usize;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn CFEqual(left: Object, right: Object) -> u8;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn CFArrayGetTypeID() -> usize;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn CFArrayGetCount(array: Object) -> isize;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn CFArrayGetValueAtIndex(array: Object, index: isize) -> Object;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn CFDictionaryGetTypeID() -> usize;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn CFDictionaryGetValue(dictionary: Object, key: Object) -> Object;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn CFStringGetTypeID() -> usize;
|
||||||
|
fn CFStringCreateWithCString(
|
||||||
|
allocator: Object,
|
||||||
|
string: *const c_char,
|
||||||
|
encoding: u32,
|
||||||
|
) -> Object;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn CFBooleanGetTypeID() -> usize;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn CFBooleanGetValue(boolean: Object) -> u8;
|
||||||
|
fn CFNumberGetTypeID() -> usize;
|
||||||
|
fn CFNumberGetValue(number: Object, kind: isize, value: *mut c_void) -> u8;
|
||||||
|
#[cfg(test)]
|
||||||
|
fn CFNumberCreate(allocator: Object, kind: isize, value: Object) -> Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only Copy/Create results belong here; power source descriptions are borrowed.
|
||||||
|
struct Owned(Object);
|
||||||
|
|
||||||
|
impl Owned {
|
||||||
|
fn new(object: Object) -> Option<Self> {
|
||||||
|
(!object.is_null()).then(|| Self(object))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string(value: &CStr) -> Option<Self> {
|
||||||
|
// kCFStringEncodingUTF8, from CFString.h; a null allocator is the default.
|
||||||
|
Self::new(unsafe {
|
||||||
|
CFStringCreateWithCString(std::ptr::null(), value.as_ptr(), 0x0800_0100)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Owned {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { CFRelease(self.0) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn is_type(object: Object, kind: usize) -> bool {
|
||||||
|
!object.is_null() && unsafe { CFGetTypeID(object) == kind }
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn number(object: Object) -> Option<u64> {
|
||||||
|
if !unsafe { is_type(object, CFNumberGetTypeID()) } {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut value: i64 = 0;
|
||||||
|
// kCFNumberSInt64Type = 4. A lossy/out-of-range conversion returns false.
|
||||||
|
let converted = unsafe { CFNumberGetValue(object, 4, (&mut value as *mut i64).cast()) };
|
||||||
|
(converted != 0).then_some(value)?.try_into().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
pub(super) fn read_charge() -> Option<u8> {
|
||||||
|
// The opaque snapshot is only passed to IOPS accessors. All owned values
|
||||||
|
// are released on every return path, including failed type checks.
|
||||||
|
unsafe {
|
||||||
|
let info = Owned::new(IOPSCopyPowerSourcesInfo())?;
|
||||||
|
let sources = Owned::new(IOPSCopyPowerSourcesList(info.0))?;
|
||||||
|
if !is_type(sources.0, CFArrayGetTypeID()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let type_key = Owned::string(c"Type")?;
|
||||||
|
let internal = Owned::string(c"InternalBattery")?;
|
||||||
|
let present_key = Owned::string(c"Is Present")?;
|
||||||
|
let current_key = Owned::string(c"Current Capacity")?;
|
||||||
|
let full_key = Owned::string(c"Max Capacity")?;
|
||||||
|
for index in 0..CFArrayGetCount(sources.0) {
|
||||||
|
let source = CFArrayGetValueAtIndex(sources.0, index);
|
||||||
|
if source.is_null() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let description = IOPSGetPowerSourceDescription(info.0, source);
|
||||||
|
if !is_type(description, CFDictionaryGetTypeID()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let kind = CFDictionaryGetValue(description, type_key.0);
|
||||||
|
if !is_type(kind, CFStringGetTypeID()) || CFEqual(kind, internal.0) == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let present = CFDictionaryGetValue(description, present_key.0);
|
||||||
|
if !is_type(present, CFBooleanGetTypeID()) || CFBooleanGetValue(present) == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let current = number(CFDictionaryGetValue(description, current_key.0))?;
|
||||||
|
let full = number(CFDictionaryGetValue(description, full_key.0))?;
|
||||||
|
return super::percentage(current, full);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_values_are_type_checked_and_numbers_must_be_exact_and_nonnegative() {
|
||||||
|
assert!(Owned::new(std::ptr::null()).is_none());
|
||||||
|
unsafe {
|
||||||
|
assert_eq!(number(std::ptr::null()), None);
|
||||||
|
let text = Owned::string(c"27").unwrap();
|
||||||
|
assert_eq!(number(text.0), None);
|
||||||
|
for value in [-1_i64, 0, 100] {
|
||||||
|
let object = Owned::new(CFNumberCreate(
|
||||||
|
std::ptr::null(),
|
||||||
|
4,
|
||||||
|
(&value as *const i64).cast(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(number(object.0), u64::try_from(value).ok());
|
||||||
|
}
|
||||||
|
let fraction = 1.5_f64;
|
||||||
|
let object = Owned::new(CFNumberCreate(
|
||||||
|
std::ptr::null(),
|
||||||
|
6,
|
||||||
|
(&fraction as *const f64).cast(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(number(object.0), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "linux", test))]
|
||||||
|
mod linux {
|
||||||
|
use std::{fs, io, path::Path};
|
||||||
|
|
||||||
|
pub(super) fn read_charge(root: &Path) -> Option<u8> {
|
||||||
|
for entry in fs::read_dir(root).ok()? {
|
||||||
|
let path = entry.ok()?.path();
|
||||||
|
if fs::read_to_string(path.join("type"))
|
||||||
|
.ok()
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
!= Some("Battery")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match fs::read_to_string(path.join("scope")) {
|
||||||
|
Ok(scope) if matches!(scope.trim(), "System" | "Unknown") => {}
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
if fs::read_to_string(path.join("present"))
|
||||||
|
.ok()
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
!= Some("1")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return read_capacity(&path);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_capacity(path: &Path) -> Option<u8> {
|
||||||
|
match fs::read_to_string(path.join("capacity")) {
|
||||||
|
Ok(capacity) => {
|
||||||
|
return capacity
|
||||||
|
.trim()
|
||||||
|
.parse::<u8>()
|
||||||
|
.ok()
|
||||||
|
.filter(|value| *value <= 100);
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||||
|
Err(_) => return None,
|
||||||
|
}
|
||||||
|
// Never mix energy and charge units or use design capacity as full capacity.
|
||||||
|
let (current, full) = match fs::read_to_string(path.join("energy_now")) {
|
||||||
|
Ok(current) => (current, fs::read_to_string(path.join("energy_full")).ok()?),
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => (
|
||||||
|
fs::read_to_string(path.join("charge_now")).ok()?,
|
||||||
|
fs::read_to_string(path.join("charge_full")).ok()?,
|
||||||
|
),
|
||||||
|
Err(_) => return None,
|
||||||
|
};
|
||||||
|
super::percentage(current.trim().parse().ok()?, full.trim().parse().ok()?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
fn windows_charge(percent: u8, flags: u8) -> Option<u8> {
|
||||||
|
(percent <= 100 && flags & 128 == 0).then_some(percent)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(windows, not(test)))]
|
||||||
|
mod windows {
|
||||||
|
#[repr(C)]
|
||||||
|
struct SystemPowerStatus {
|
||||||
|
ac_line_status: u8,
|
||||||
|
battery_flag: u8,
|
||||||
|
battery_life_percent: u8,
|
||||||
|
system_status_flag: u8,
|
||||||
|
battery_life_time: u32,
|
||||||
|
battery_full_life_time: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[link(name = "kernel32")]
|
||||||
|
unsafe extern "system" {
|
||||||
|
fn GetSystemPowerStatus(status: *mut SystemPowerStatus) -> i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn read_charge() -> Option<u8> {
|
||||||
|
let mut status = std::mem::MaybeUninit::uninit();
|
||||||
|
if unsafe { GetSystemPowerStatus(status.as_mut_ptr()) } == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let status = unsafe { status.assume_init() };
|
||||||
|
super::windows_charge(status.battery_life_percent, status.battery_flag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
path::PathBuf,
|
||||||
|
sync::atomic::{AtomicBool, AtomicU64, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn percentages_reject_invalid_data_and_include_both_endpoints() {
|
||||||
|
assert_eq!(percentage(0, 100), Some(0));
|
||||||
|
assert_eq!(percentage(100, 100), Some(100));
|
||||||
|
assert_eq!(percentage(1, 3), Some(33));
|
||||||
|
assert_eq!(percentage(u64::MAX, u64::MAX), Some(100));
|
||||||
|
assert_eq!(percentage(0, 0), None);
|
||||||
|
assert_eq!(percentage(101, 100), None);
|
||||||
|
assert_eq!(windows_charge(0, 4), Some(0));
|
||||||
|
assert_eq!(windows_charge(100, 8), Some(100));
|
||||||
|
assert_eq!(windows_charge(101, 0), None);
|
||||||
|
assert_eq!(windows_charge(255, 0), None);
|
||||||
|
assert_eq!(windows_charge(50, 128), None);
|
||||||
|
assert_eq!(windows_charge(50, 255), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker(samples: Vec<Option<u8>>) -> (Battery, Receiver<()>, Arc<AtomicBool>) {
|
||||||
|
struct ReaderDropped(Arc<AtomicBool>);
|
||||||
|
impl Drop for ReaderDropped {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let dropped = Arc::new(AtomicBool::new(false));
|
||||||
|
let reader = ReaderDropped(Arc::clone(&dropped));
|
||||||
|
let mut samples = samples.into_iter();
|
||||||
|
let (ready, published) = mpsc::channel();
|
||||||
|
let battery = Battery::start_with(
|
||||||
|
move || {
|
||||||
|
let _ = &reader;
|
||||||
|
samples
|
||||||
|
.next()
|
||||||
|
.expect("test provides every requested sample")
|
||||||
|
},
|
||||||
|
move |wake| {
|
||||||
|
ready.send(()).unwrap();
|
||||||
|
// Explicit channel messages replace the production clock entirely.
|
||||||
|
wake.recv().is_ok()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(battery, published, dropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_reads_immediately_replaces_stale_samples_and_clears_invalid_data() {
|
||||||
|
let (mut battery, published, dropped) = worker(vec![Some(0), Some(100), None, Some(101)]);
|
||||||
|
published.recv().unwrap();
|
||||||
|
assert_eq!(battery.poll(), Some(Some(0)));
|
||||||
|
assert_eq!(battery.poll(), None);
|
||||||
|
battery.wake.as_ref().unwrap().send(()).unwrap();
|
||||||
|
published.recv().unwrap();
|
||||||
|
battery.wake.as_ref().unwrap().send(()).unwrap();
|
||||||
|
published.recv().unwrap();
|
||||||
|
assert_eq!(battery.poll(), Some(None));
|
||||||
|
assert_eq!(battery.poll(), None);
|
||||||
|
battery.wake.as_ref().unwrap().send(()).unwrap();
|
||||||
|
published.recv().unwrap();
|
||||||
|
assert_eq!(battery.poll(), Some(None));
|
||||||
|
drop(battery);
|
||||||
|
assert!(dropped.load(Ordering::Acquire));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dropping_interrupts_wait_and_joins_even_with_an_unread_sample() {
|
||||||
|
let (battery, published, dropped) = worker(vec![Some(50)]);
|
||||||
|
published.recv().unwrap();
|
||||||
|
drop(battery);
|
||||||
|
assert!(dropped.load(Ordering::Acquire));
|
||||||
|
}
|
||||||
|
|
||||||
|
static FIXTURE_ID: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
struct Fixture(PathBuf);
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn new() -> Self {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"vamp-battery-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
FIXTURE_ID.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
fs::create_dir(&path).unwrap();
|
||||||
|
Self(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source(&self, name: &str, fields: &[(&str, &str)]) {
|
||||||
|
let path = self.0.join(name);
|
||||||
|
fs::create_dir_all(&path).unwrap();
|
||||||
|
for (key, value) in fields {
|
||||||
|
fs::write(path.join(key), value).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&self) -> Option<u8> {
|
||||||
|
linux::read_charge(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
fs::remove_dir_all(&self.0).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sysfs_reads_present_system_batteries_but_not_external_or_absent_sources() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
assert_eq!(fixture.read(), None);
|
||||||
|
fixture.source("AC", &[("type", "Mains"), ("capacity", "100")]);
|
||||||
|
fixture.source(
|
||||||
|
"mouse",
|
||||||
|
&[
|
||||||
|
("type", "Battery"),
|
||||||
|
("scope", "Device"),
|
||||||
|
("present", "1"),
|
||||||
|
("capacity", "100"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
fixture.source(
|
||||||
|
"absent",
|
||||||
|
&[("type", "Battery"), ("present", "0"), ("capacity", "100")],
|
||||||
|
);
|
||||||
|
assert_eq!(fixture.read(), None);
|
||||||
|
fixture.source(
|
||||||
|
"BAT0",
|
||||||
|
&[
|
||||||
|
("type", "Battery\n"),
|
||||||
|
("scope", "System\n"),
|
||||||
|
("present", "1\n"),
|
||||||
|
("capacity", "27\n"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(fixture.read(), Some(27));
|
||||||
|
fixture.source("BAT0", &[("capacity", "0")]);
|
||||||
|
assert_eq!(fixture.read(), Some(0));
|
||||||
|
fixture.source("BAT0", &[("capacity", "100")]);
|
||||||
|
assert_eq!(fixture.read(), Some(100));
|
||||||
|
for invalid in ["101", "-1", "255", "garbage", ""] {
|
||||||
|
fixture.source("BAT0", &[("capacity", invalid)]);
|
||||||
|
assert_eq!(fixture.read(), None);
|
||||||
|
}
|
||||||
|
fs::remove_file(fixture.0.join("BAT0/capacity")).unwrap();
|
||||||
|
assert_eq!(fixture.read(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sysfs_uses_matching_actual_energy_or_charge_totals_only() {
|
||||||
|
let energy = Fixture::new();
|
||||||
|
energy.source(
|
||||||
|
"BAT0",
|
||||||
|
&[
|
||||||
|
("type", "Battery"),
|
||||||
|
("present", "1"),
|
||||||
|
("energy_now", "27"),
|
||||||
|
("energy_full", "100"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(energy.read(), Some(27));
|
||||||
|
energy.source("BAT0", &[("energy_full", "0")]);
|
||||||
|
assert_eq!(energy.read(), None);
|
||||||
|
energy.source("BAT0", &[("energy_full", "26")]);
|
||||||
|
assert_eq!(energy.read(), None);
|
||||||
|
energy.source("BAT0", &[("energy_full", "garbage")]);
|
||||||
|
assert_eq!(energy.read(), None);
|
||||||
|
let charge = Fixture::new();
|
||||||
|
charge.source(
|
||||||
|
"BAT0",
|
||||||
|
&[
|
||||||
|
("type", "Battery"),
|
||||||
|
("present", "1"),
|
||||||
|
("charge_now", "15"),
|
||||||
|
("charge_full", "20"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(charge.read(), Some(75));
|
||||||
|
charge.source("BAT0", &[("energy_now", "5")]);
|
||||||
|
assert_eq!(charge.read(), None);
|
||||||
|
let design = Fixture::new();
|
||||||
|
design.source(
|
||||||
|
"BAT0",
|
||||||
|
&[
|
||||||
|
("type", "Battery"),
|
||||||
|
("present", "1"),
|
||||||
|
("energy_now", "20"),
|
||||||
|
("energy_full_design", "100"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(design.read(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
+538
@@ -0,0 +1,538 @@
|
|||||||
|
use serde::{Deserialize, Deserializer};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PopupAnchor {
|
||||||
|
TopLeft,
|
||||||
|
TopCenter,
|
||||||
|
TopRight,
|
||||||
|
CenterLeft,
|
||||||
|
Center,
|
||||||
|
CenterRight,
|
||||||
|
#[default]
|
||||||
|
BottomLeft,
|
||||||
|
BottomCenter,
|
||||||
|
BottomRight,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct PopupOptions {
|
||||||
|
pub anchor: PopupAnchor,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct PopupConfig {
|
||||||
|
pub command: PopupOptions,
|
||||||
|
pub search: PopupOptions,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for PopupOptions {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct Options {
|
||||||
|
#[serde(default)]
|
||||||
|
anchor: PopupAnchor,
|
||||||
|
}
|
||||||
|
let options: Options = deserialize_object(deserializer)?;
|
||||||
|
Ok(Self {
|
||||||
|
anchor: options.anchor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for PopupConfig {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct Options {
|
||||||
|
#[serde(default)]
|
||||||
|
command: PopupOptions,
|
||||||
|
#[serde(default)]
|
||||||
|
search: PopupOptions,
|
||||||
|
}
|
||||||
|
let options: Options = deserialize_object(deserializer)?;
|
||||||
|
Ok(Self {
|
||||||
|
command: options.command,
|
||||||
|
search: options.search,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Struct derives also accept sequences; popup configuration accepts only objects.
|
||||||
|
fn deserialize_object<'de, D: Deserializer<'de>, T: Deserialize<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<T, D::Error> {
|
||||||
|
struct ObjectVisitor<T>(std::marker::PhantomData<T>);
|
||||||
|
|
||||||
|
impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for ObjectVisitor<T> {
|
||||||
|
type Value = T;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
formatter.write_str("a popup configuration object")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_map<M: serde::de::MapAccess<'de>>(self, map: M) -> Result<T, M::Error> {
|
||||||
|
T::deserialize(serde::de::value::MapAccessDeserializer::new(map))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_map(ObjectVisitor(std::marker::PhantomData))
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BOTTOM_SEGMENTS: &[&str] = &[
|
||||||
|
"mode",
|
||||||
|
"pending",
|
||||||
|
"buffer",
|
||||||
|
"diagnostics",
|
||||||
|
"path",
|
||||||
|
"selections",
|
||||||
|
"count",
|
||||||
|
"language",
|
||||||
|
"lsp",
|
||||||
|
"position",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ChromeLine {
|
||||||
|
pub left: Vec<String>,
|
||||||
|
pub center: Vec<String>,
|
||||||
|
pub right: Vec<String>,
|
||||||
|
pub segments: BTreeMap<String, SegmentConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SegmentConfig {
|
||||||
|
pub title: String,
|
||||||
|
pub padding: usize,
|
||||||
|
pub leading_edge: String,
|
||||||
|
pub trailing_edge: String,
|
||||||
|
pub first_leading_edge: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SegmentConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
title: String::new(),
|
||||||
|
padding: 1,
|
||||||
|
leading_edge: String::new(),
|
||||||
|
trailing_edge: String::new(),
|
||||||
|
first_leading_edge: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChromeLine {
|
||||||
|
pub fn top() -> Self {
|
||||||
|
Self {
|
||||||
|
left: vec!["tabs".into()],
|
||||||
|
center: Vec::new(),
|
||||||
|
right: vec!["battery".into()],
|
||||||
|
segments: BTreeMap::from([
|
||||||
|
("tabs".into(), default_segment("tabs")),
|
||||||
|
("battery".into(), default_segment("battery")),
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bottom() -> Self {
|
||||||
|
Self {
|
||||||
|
left: DEFAULT_BOTTOM_SEGMENTS[..DEFAULT_BOTTOM_SEGMENTS.len() - 3]
|
||||||
|
.iter()
|
||||||
|
.map(|name| (*name).into())
|
||||||
|
.collect(),
|
||||||
|
center: Vec::new(),
|
||||||
|
right: vec!["language".into(), "lsp".into(), "position".into()],
|
||||||
|
segments: DEFAULT_BOTTOM_SEGMENTS
|
||||||
|
.iter()
|
||||||
|
.map(|name| ((*name).into(), default_segment(name)))
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Styles are populated for every referenced name during configuration validation.
|
||||||
|
pub fn segment(&self, name: &str) -> &SegmentConfig {
|
||||||
|
self.segments.get(name).expect("validated chrome segment")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&mut self) -> Result<(), String> {
|
||||||
|
for name in self
|
||||||
|
.left
|
||||||
|
.iter()
|
||||||
|
.chain(&self.center)
|
||||||
|
.chain(&self.right)
|
||||||
|
.chain(self.segments.keys())
|
||||||
|
{
|
||||||
|
validate_name(name)?;
|
||||||
|
}
|
||||||
|
for (name, segment) in &self.segments {
|
||||||
|
if segment.padding > 64 {
|
||||||
|
return Err(format!(
|
||||||
|
"Chrome segment {name:?} padding must be between 0 and 64"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (option, text) in [
|
||||||
|
("title", Some(&segment.title)),
|
||||||
|
("leading_edge", Some(&segment.leading_edge)),
|
||||||
|
("trailing_edge", Some(&segment.trailing_edge)),
|
||||||
|
("first_leading_edge", segment.first_leading_edge.as_ref()),
|
||||||
|
] {
|
||||||
|
if text.is_some_and(|text| text.chars().any(char::is_control)) {
|
||||||
|
return Err(format!(
|
||||||
|
"Chrome segment {name:?} {option} must be single-line text without controls"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validate_title(&segment.title)
|
||||||
|
.map_err(|error| format!("Chrome segment {name:?}: {error}"))?;
|
||||||
|
}
|
||||||
|
for name in self.left.iter().chain(&self.center).chain(&self.right) {
|
||||||
|
if !self.segments.contains_key(name) {
|
||||||
|
self.segments.insert(name.clone(), default_segment(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_segment(name: &str) -> SegmentConfig {
|
||||||
|
let mut segment = SegmentConfig::default();
|
||||||
|
if matches!(name, "tabs" | "position" | "battery") {
|
||||||
|
segment.leading_edge = "░▒▓".into();
|
||||||
|
}
|
||||||
|
if matches!(name, "tabs" | "mode") {
|
||||||
|
segment.trailing_edge = "▓▒░".into();
|
||||||
|
}
|
||||||
|
if name == "tabs" {
|
||||||
|
segment.title = "{index} {view}{name}{modified}".into();
|
||||||
|
segment.first_leading_edge = Some(String::new());
|
||||||
|
}
|
||||||
|
segment
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_name(name: &str) -> Result<(), String> {
|
||||||
|
let mut bytes = name.bytes();
|
||||||
|
if !bytes
|
||||||
|
.next()
|
||||||
|
.is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
|
||||||
|
|| !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"Label/segment name {name:?} must match [A-Za-z_][A-Za-z0-9_]*"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_title(title: &str) -> Result<(), String> {
|
||||||
|
let mut rest = title;
|
||||||
|
while let Some(index) = rest.find(['{', '}']) {
|
||||||
|
rest = &rest[index..];
|
||||||
|
if !rest.starts_with('{') {
|
||||||
|
return Err("title contains an unmatched '}'".into());
|
||||||
|
}
|
||||||
|
let end = rest.find('}').ok_or("title contains an unmatched '{'")?;
|
||||||
|
let placeholder = &rest[1..end];
|
||||||
|
if !matches!(placeholder, "index" | "view" | "name" | "path" | "modified") {
|
||||||
|
return Err(format!(
|
||||||
|
"unknown title placeholder {{{placeholder}}}; expected index, view, name, path, or modified"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
rest = &rest[end + 1..];
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LineOptions {
|
||||||
|
left: Option<Vec<String>>,
|
||||||
|
center: Option<Vec<String>>,
|
||||||
|
right: Option<Vec<String>>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
segments: BTreeMap<String, SegmentOptions>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct SegmentOptions {
|
||||||
|
title: Option<String>,
|
||||||
|
padding: Option<usize>,
|
||||||
|
leading_edge: Option<String>,
|
||||||
|
trailing_edge: Option<String>,
|
||||||
|
first_leading_edge: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_line<'de, D: Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
mut line: ChromeLine,
|
||||||
|
) -> Result<ChromeLine, D::Error> {
|
||||||
|
// Decode through a value so explicit null is rejected rather than treated as an omitted option.
|
||||||
|
let value = serde_json::Value::deserialize(deserializer)?;
|
||||||
|
let object = value
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| serde::de::Error::custom("chrome line must be an object"))?;
|
||||||
|
for (name, value) in object {
|
||||||
|
if value.is_null()
|
||||||
|
|| value
|
||||||
|
.as_object()
|
||||||
|
.is_some_and(|options| options.values().any(serde_json::Value::is_null))
|
||||||
|
{
|
||||||
|
return Err(serde::de::Error::custom(format!(
|
||||||
|
"Chrome option {name:?} must not be null"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let options: LineOptions = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
|
||||||
|
if let Some(left) = options.left {
|
||||||
|
line.left = left;
|
||||||
|
}
|
||||||
|
if let Some(center) = options.center {
|
||||||
|
line.center = center;
|
||||||
|
}
|
||||||
|
if let Some(right) = options.right {
|
||||||
|
line.right = right;
|
||||||
|
}
|
||||||
|
for (name, options) in options.segments {
|
||||||
|
let segment = line
|
||||||
|
.segments
|
||||||
|
.entry(name.clone())
|
||||||
|
.or_insert_with(|| default_segment(&name));
|
||||||
|
if let Some(title) = options.title {
|
||||||
|
segment.title = title;
|
||||||
|
}
|
||||||
|
if let Some(padding) = options.padding {
|
||||||
|
segment.padding = padding;
|
||||||
|
}
|
||||||
|
if let Some(edge) = options.leading_edge {
|
||||||
|
segment.leading_edge = edge;
|
||||||
|
}
|
||||||
|
if let Some(edge) = options.trailing_edge {
|
||||||
|
segment.trailing_edge = edge;
|
||||||
|
}
|
||||||
|
if let Some(edge) = options.first_leading_edge {
|
||||||
|
segment.first_leading_edge = Some(edge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line.validate().map_err(serde::de::Error::custom)?;
|
||||||
|
Ok(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn top<'de, D: Deserializer<'de>>(deserializer: D) -> Result<ChromeLine, D::Error> {
|
||||||
|
deserialize_line(deserializer, ChromeLine::top())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bottom<'de, D: Deserializer<'de>>(deserializer: D) -> Result<ChromeLine, D::Error> {
|
||||||
|
deserialize_line(deserializer, ChromeLine::bottom())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn labels<'de, D: Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<BTreeMap<String, String>, D::Error> {
|
||||||
|
let labels = BTreeMap::<String, String>::deserialize(deserializer)?;
|
||||||
|
for name in labels.keys() {
|
||||||
|
validate_name(name).map_err(serde::de::Error::custom)?;
|
||||||
|
}
|
||||||
|
Ok(labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn popup_partial_options_keep_command_and_search_independent() {
|
||||||
|
let popup: PopupConfig = serde_json::from_value(serde_json::json!({
|
||||||
|
"command": {"anchor": "top_right"}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(popup.command.anchor, PopupAnchor::TopRight);
|
||||||
|
assert_eq!(popup.search.anchor, PopupAnchor::BottomLeft);
|
||||||
|
let popup: PopupConfig = serde_json::from_value(serde_json::json!({
|
||||||
|
"command": {}, "search": {"anchor": "center_left"}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(popup.command.anchor, PopupAnchor::BottomLeft);
|
||||||
|
assert_eq!(popup.search.anchor, PopupAnchor::CenterLeft);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_popup_options_are_rejected_at_the_boundary() {
|
||||||
|
for value in [
|
||||||
|
serde_json::json!(null),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!(7),
|
||||||
|
serde_json::json!({"selector": {}}),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<PopupConfig>(value.clone()).is_err(),
|
||||||
|
"{value}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for options in [
|
||||||
|
serde_json::json!(null),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!("center"),
|
||||||
|
serde_json::json!({"anchor": null}),
|
||||||
|
serde_json::json!({"anchor": 7}),
|
||||||
|
serde_json::json!({"anchor": true}),
|
||||||
|
serde_json::json!({"anchor": []}),
|
||||||
|
serde_json::json!({"anchor": {}}),
|
||||||
|
serde_json::json!({"anchor": "middle"}),
|
||||||
|
serde_json::json!({"anchor": "TopLeft"}),
|
||||||
|
serde_json::json!({"width": 72}),
|
||||||
|
serde_json::json!({"anchor": "center", "callback": "run"}),
|
||||||
|
] {
|
||||||
|
for mode in ["command", "search"] {
|
||||||
|
let mut popup = serde_json::json!({});
|
||||||
|
popup[mode] = options.clone();
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<PopupConfig>(popup).is_err(),
|
||||||
|
"{mode}: {options}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Lines {
|
||||||
|
#[serde(deserialize_with = "top")]
|
||||||
|
top: ChromeLine,
|
||||||
|
#[serde(deserialize_with = "bottom")]
|
||||||
|
bottom: ChromeLine,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bottom_keeps_metadata_on_the_right_without_filled_styles() {
|
||||||
|
let line = ChromeLine::bottom();
|
||||||
|
assert_eq!(
|
||||||
|
line.left,
|
||||||
|
[
|
||||||
|
"mode",
|
||||||
|
"pending",
|
||||||
|
"buffer",
|
||||||
|
"diagnostics",
|
||||||
|
"path",
|
||||||
|
"selections",
|
||||||
|
"count"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(line.right, ["language", "lsp", "position"]);
|
||||||
|
for name in ["language", "lsp"] {
|
||||||
|
assert_eq!(line.segment(name), &SegmentConfig::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn omitted_options_inherit_and_explicit_groups_replace_defaults() {
|
||||||
|
let lines: Lines = serde_json::from_value(serde_json::json!({
|
||||||
|
"top": {"right": ["battery"], "tabs": {"title": "T{index}:{path}{modified}"}, "battery": {"padding": 0}},
|
||||||
|
"bottom": {"left": [], "center": ["workspace"]}
|
||||||
|
})).unwrap();
|
||||||
|
assert_eq!(lines.top.left, ["tabs"]);
|
||||||
|
assert_eq!(lines.top.segment("tabs").leading_edge, "░▒▓");
|
||||||
|
assert_eq!(lines.top.segment("battery").padding, 0);
|
||||||
|
assert!(lines.bottom.left.is_empty());
|
||||||
|
assert_eq!(lines.bottom.right, ["language", "lsp", "position"]);
|
||||||
|
assert_eq!(lines.bottom.segment("workspace"), &SegmentConfig::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_positions_accept_all_builtins_and_custom_styles() {
|
||||||
|
let names = [
|
||||||
|
"tabs",
|
||||||
|
"mode",
|
||||||
|
"pending",
|
||||||
|
"buffer",
|
||||||
|
"diagnostics",
|
||||||
|
"path",
|
||||||
|
"selections",
|
||||||
|
"count",
|
||||||
|
"language",
|
||||||
|
"lsp",
|
||||||
|
"position",
|
||||||
|
"battery",
|
||||||
|
"workspace",
|
||||||
|
];
|
||||||
|
let options = serde_json::json!({
|
||||||
|
"left": names,
|
||||||
|
"center": ["language"],
|
||||||
|
"right": ["tabs"],
|
||||||
|
"tabs": {"title": "{index}:{name}:{path}{modified}", "padding": 0},
|
||||||
|
"mode": {"leading_edge": "<", "trailing_edge": ">"},
|
||||||
|
"workspace": {"first_leading_edge": ""}
|
||||||
|
});
|
||||||
|
let lines: Lines = serde_json::from_value(serde_json::json!({
|
||||||
|
"top": options,
|
||||||
|
"bottom": options,
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
for line in [&lines.top, &lines.bottom] {
|
||||||
|
assert_eq!(line.left, names);
|
||||||
|
assert_eq!(line.center, ["language"]);
|
||||||
|
assert_eq!(line.right, ["tabs"]);
|
||||||
|
assert_eq!(line.segment("tabs").padding, 0);
|
||||||
|
assert_eq!(line.segment("mode").leading_edge, "<");
|
||||||
|
assert_eq!(
|
||||||
|
line.segment("workspace").first_leading_edge.as_deref(),
|
||||||
|
Some("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn view_placeholder_is_valid_in_every_segment_and_bar() {
|
||||||
|
for name in [
|
||||||
|
"tabs",
|
||||||
|
"mode",
|
||||||
|
"pending",
|
||||||
|
"buffer",
|
||||||
|
"diagnostics",
|
||||||
|
"path",
|
||||||
|
"selections",
|
||||||
|
"count",
|
||||||
|
"language",
|
||||||
|
"lsp",
|
||||||
|
"position",
|
||||||
|
"battery",
|
||||||
|
"workspace",
|
||||||
|
] {
|
||||||
|
let options = serde_json::json!({
|
||||||
|
"left": [name],
|
||||||
|
(name): {"title": "{index}:{view}{name}:{path}{modified}"}
|
||||||
|
});
|
||||||
|
let lines: Lines = serde_json::from_value(serde_json::json!({
|
||||||
|
"top": options,
|
||||||
|
"bottom": options,
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
for line in [&lines.top, &lines.bottom] {
|
||||||
|
assert_eq!(
|
||||||
|
line.segment(name).title,
|
||||||
|
"{index}:{view}{name}:{path}{modified}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_chrome_is_rejected_at_the_boundary() {
|
||||||
|
for options in [
|
||||||
|
serde_json::json!({"left": ["bad-name"]}),
|
||||||
|
serde_json::json!({"left": null}),
|
||||||
|
serde_json::json!({"tabs": {"padding": 65}}),
|
||||||
|
serde_json::json!({"tabs": {"padding": null}}),
|
||||||
|
serde_json::json!({"tabs": {"leading_edge": "\n"}}),
|
||||||
|
serde_json::json!({"tabs": {"title": "{unknown}"}}),
|
||||||
|
serde_json::json!({"tabs": {"title": "{name"}}),
|
||||||
|
serde_json::json!({"tabs": {"title": "name}"}}),
|
||||||
|
serde_json::json!({"tabs": {"colour": "red"}}),
|
||||||
|
] {
|
||||||
|
for position in ["top", "bottom"] {
|
||||||
|
let mut frame = serde_json::json!({"top": {}, "bottom": {}});
|
||||||
|
frame[position] = options.clone();
|
||||||
|
assert!(serde_json::from_value::<Lines>(frame).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+155
-69
@@ -164,12 +164,6 @@ pub enum DisplayRow {
|
|||||||
Fold { start: usize, end: usize },
|
Fold { start: usize, end: usize },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct SelectorPosition {
|
|
||||||
category: usize,
|
|
||||||
query: String,
|
|
||||||
selected: RevisionTarget,
|
|
||||||
}
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
enum RequestKind {
|
enum RequestKind {
|
||||||
Catalog,
|
Catalog,
|
||||||
@@ -194,6 +188,7 @@ pub struct DiffState {
|
|||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub comparison: Option<Comparison>,
|
pub comparison: Option<Comparison>,
|
||||||
pub comparison_id: Option<u64>,
|
pub comparison_id: Option<u64>,
|
||||||
|
pub(crate) snapshot_roots: [Option<PathBuf>; 2],
|
||||||
pub files: Option<FilePicker>,
|
pub files: Option<FilePicker>,
|
||||||
pub files_visible: bool,
|
pub files_visible: bool,
|
||||||
pub file: usize,
|
pub file: usize,
|
||||||
@@ -214,7 +209,6 @@ pub struct DiffState {
|
|||||||
request_kind: Option<RequestKind>,
|
request_kind: Option<RequestKind>,
|
||||||
visible: Vec<usize>,
|
visible: Vec<usize>,
|
||||||
search_labels: Vec<String>,
|
search_labels: Vec<String>,
|
||||||
before_position: Option<SelectorPosition>,
|
|
||||||
max_before: usize,
|
max_before: usize,
|
||||||
max_after: usize,
|
max_after: usize,
|
||||||
after_widths: Vec<usize>,
|
after_widths: Vec<usize>,
|
||||||
@@ -236,6 +230,7 @@ impl DiffState {
|
|||||||
comparison: None,
|
comparison: None,
|
||||||
comparison_id: None,
|
comparison_id: None,
|
||||||
files: None,
|
files: None,
|
||||||
|
snapshot_roots: [None, None],
|
||||||
files_visible: true,
|
files_visible: true,
|
||||||
file: 0,
|
file: 0,
|
||||||
focus: Focus::Files,
|
focus: Focus::Files,
|
||||||
@@ -255,7 +250,6 @@ impl DiffState {
|
|||||||
request_kind: Some(RequestKind::Catalog),
|
request_kind: Some(RequestKind::Catalog),
|
||||||
visible: Vec::new(),
|
visible: Vec::new(),
|
||||||
search_labels: Vec::new(),
|
search_labels: Vec::new(),
|
||||||
before_position: None,
|
|
||||||
max_before: 0,
|
max_before: 0,
|
||||||
max_after: 0,
|
max_after: 0,
|
||||||
after_widths: Vec::new(),
|
after_widths: Vec::new(),
|
||||||
@@ -272,6 +266,10 @@ impl DiffState {
|
|||||||
self.pending.take()
|
self.pending.take()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn request_queued(&self) -> bool {
|
||||||
|
self.pending.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
fn queue(&mut self, operation: Operation) {
|
fn queue(&mut self, operation: Operation) {
|
||||||
self.request_kind = Some(match operation {
|
self.request_kind = Some(match operation {
|
||||||
Operation::Compare { .. } => RequestKind::Comparison,
|
Operation::Compare { .. } => RequestKind::Comparison,
|
||||||
@@ -299,7 +297,6 @@ impl DiffState {
|
|||||||
..
|
..
|
||||||
}) = &result
|
}) = &result
|
||||||
&& (self.comparison_id != Some(*comparison_id)
|
&& (self.comparison_id != Some(*comparison_id)
|
||||||
|| self.file != *file_index
|
|
||||||
|| self.request_kind
|
|| self.request_kind
|
||||||
!= Some(RequestKind::File {
|
!= Some(RequestKind::File {
|
||||||
comparison_id: *comparison_id,
|
comparison_id: *comparison_id,
|
||||||
@@ -355,6 +352,7 @@ impl DiffState {
|
|||||||
self.files = Some(files);
|
self.files = Some(files);
|
||||||
self.comparison = Some(comparison);
|
self.comparison = Some(comparison);
|
||||||
self.comparison_id = Some(id);
|
self.comparison_id = Some(id);
|
||||||
|
self.snapshot_roots = [None, None];
|
||||||
self.phase = Phase::View;
|
self.phase = Phase::View;
|
||||||
self.file = 0;
|
self.file = 0;
|
||||||
self.focus = if self.files_visible {
|
self.focus = if self.files_visible {
|
||||||
@@ -387,8 +385,10 @@ impl DiffState {
|
|||||||
comparison.editable_paths.remove(&file.path);
|
comparison.editable_paths.remove(&file.path);
|
||||||
}
|
}
|
||||||
*selected = file;
|
*selected = file;
|
||||||
|
if self.file == file_index {
|
||||||
self.align_file();
|
self.align_file();
|
||||||
self.normalize_focus();
|
self.normalize_focus();
|
||||||
|
}
|
||||||
self.error = None;
|
self.error = None;
|
||||||
} else {
|
} else {
|
||||||
self.error = Some("Unexpected changed-file response".into());
|
self.error = Some("Unexpected changed-file response".into());
|
||||||
@@ -468,44 +468,20 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles modal input; closing is an explicit editor action.
|
/// Handles selector filtering and comparison navigation; the editor owns selector closing.
|
||||||
pub(crate) fn handle(&mut self, input: Input) {
|
pub(crate) fn handle(&mut self, input: Input) {
|
||||||
if input == Input::Escape {
|
if input == Input::Escape {
|
||||||
if self.phase == Phase::View
|
if self.phase == Phase::View
|
||||||
&& self.focus == Focus::Files
|
&& self.focus == Focus::Files
|
||||||
&& let Some(files) = &mut self.files
|
&& let Some(files) = &mut self.files
|
||||||
&& files.searching
|
|
||||||
&& files.view == FileView::Tree
|
|
||||||
{
|
{
|
||||||
|
if files.searching && files.view == FileView::Tree {
|
||||||
files.finish_search();
|
files.finish_search();
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
if self.phase == Phase::View {
|
|
||||||
if self.focus == Focus::Files
|
|
||||||
&& let Some(files) = &mut self.files
|
|
||||||
{
|
|
||||||
files.focused = false;
|
files.focused = false;
|
||||||
self.files_visible = files.presentation == FilePresentation::Docked;
|
self.files_visible = files.presentation == FilePresentation::Docked;
|
||||||
self.focus = self.first_text_focus();
|
self.focus = self.first_text_focus();
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.pending = None;
|
|
||||||
self.request_kind = None;
|
|
||||||
self.loading = false;
|
|
||||||
self.error = None;
|
|
||||||
if self.phase == Phase::Before {
|
|
||||||
self.query.clear();
|
|
||||||
self.refilter();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.phase = Phase::Before;
|
|
||||||
self.after = None;
|
|
||||||
if let Some(position) = self.before_position.take() {
|
|
||||||
self.category = position.category;
|
|
||||||
self.query = position.query;
|
|
||||||
self.refilter();
|
|
||||||
self.select_target(&position.selected);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -562,11 +538,6 @@ impl DiffState {
|
|||||||
} else if let RevisionTarget::ListRemote(remote) = &revision.target {
|
} else if let RevisionTarget::ListRemote(remote) = &revision.target {
|
||||||
self.queue(Operation::ListRemote(remote.clone()));
|
self.queue(Operation::ListRemote(remote.clone()));
|
||||||
} else if self.phase == Phase::Before {
|
} else if self.phase == Phase::Before {
|
||||||
self.before_position = Some(SelectorPosition {
|
|
||||||
category: self.category,
|
|
||||||
query: self.query.clone(),
|
|
||||||
selected: revision.target.clone(),
|
|
||||||
});
|
|
||||||
self.before = Some(revision);
|
self.before = Some(revision);
|
||||||
self.after = None;
|
self.after = None;
|
||||||
self.phase = Phase::After;
|
self.phase = Phase::After;
|
||||||
@@ -779,9 +750,13 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn after_editable(&self) -> bool {
|
pub fn after_editable(&self) -> bool {
|
||||||
|
matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After)
|
||||||
|
&& self.working_after_available()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn working_after_available(&self) -> bool {
|
||||||
self.phase == Phase::View
|
self.phase == Phase::View
|
||||||
&& self.selected_file_loaded()
|
&& self.selected_file_loaded()
|
||||||
&& matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After)
|
|
||||||
&& self
|
&& self
|
||||||
.after
|
.after
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -789,6 +764,7 @@ impl DiffState {
|
|||||||
&& self.comparison.as_ref().is_some_and(|comparison| {
|
&& self.comparison.as_ref().is_some_and(|comparison| {
|
||||||
comparison.files.get(self.file).is_some_and(|file| {
|
comparison.files.get(self.file).is_some_and(|file| {
|
||||||
comparison.editable_paths.contains(&file.path)
|
comparison.editable_paths.contains(&file.path)
|
||||||
|
&& file.status != FileStatus::Removed
|
||||||
&& file
|
&& file
|
||||||
.notice
|
.notice
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -807,6 +783,20 @@ impl DiffState {
|
|||||||
}) else {
|
}) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
self.select_file_index(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn select_file_index(&mut self, index: usize) -> bool {
|
||||||
|
let Some(file) = self
|
||||||
|
.comparison
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|comparison| comparison.files.get(index))
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if let Some(files) = &mut self.files {
|
||||||
|
files.reveal_path(&file.path);
|
||||||
|
}
|
||||||
self.file = index;
|
self.file = index;
|
||||||
self.normalize_focus();
|
self.normalize_focus();
|
||||||
self.live_buffer = None;
|
self.live_buffer = None;
|
||||||
@@ -846,6 +836,69 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn load_file(&mut self, file_index: usize) {
|
||||||
|
if let Some(comparison_id) = self.comparison_id {
|
||||||
|
self.queue(Operation::LoadFile {
|
||||||
|
comparison_id,
|
||||||
|
file_index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn source_range_changed(
|
||||||
|
&self,
|
||||||
|
file_index: usize,
|
||||||
|
focus: Focus,
|
||||||
|
first: usize,
|
||||||
|
last: usize,
|
||||||
|
) -> bool {
|
||||||
|
if file_index == self.file {
|
||||||
|
return self.rows.iter().any(|aligned| {
|
||||||
|
aligned.kind != RowKind::Equal
|
||||||
|
&& match focus {
|
||||||
|
Focus::Before => aligned
|
||||||
|
.before
|
||||||
|
.is_some_and(|row| first <= row && row <= last),
|
||||||
|
Focus::After => {
|
||||||
|
aligned.after.is_some_and(|row| first <= row && row <= last)
|
||||||
|
}
|
||||||
|
Focus::Files => false,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.comparison
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|comparison| comparison.files.get(file_index))
|
||||||
|
.is_some_and(|file| {
|
||||||
|
file.hunks.iter().any(|hunk| {
|
||||||
|
let (start, len) = match focus {
|
||||||
|
Focus::Before => (hunk.before_start, hunk.before_len),
|
||||||
|
Focus::After => (hunk.after_start, hunk.after_len),
|
||||||
|
Focus::Files => return false,
|
||||||
|
};
|
||||||
|
len > 0 && start <= last && first < start.saturating_add(len)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn reveal_source_row(&mut self, focus: Focus, row: usize) {
|
||||||
|
if self.source_display_row(focus, row).is_none()
|
||||||
|
&& let Some(index) = self.rows.iter().position(|aligned| match focus {
|
||||||
|
Focus::Before => aligned.before == Some(row),
|
||||||
|
Focus::After => aligned.after == Some(row),
|
||||||
|
Focus::Files => false,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
self.excerpt_ranges.push((index, index + 1));
|
||||||
|
self.excerpt_ranges.sort_unstable();
|
||||||
|
self.rebuild_display_rows();
|
||||||
|
}
|
||||||
|
if let Some(display) = self.source_display_row(focus, row) {
|
||||||
|
self.top = display;
|
||||||
|
self.follow_caret = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn source_display_row(&self, focus: Focus, source_row: usize) -> Option<usize> {
|
pub(crate) fn source_display_row(&self, focus: Focus, source_row: usize) -> Option<usize> {
|
||||||
self.display_rows.iter().position(|display| {
|
self.display_rows.iter().position(|display| {
|
||||||
matches!(*display, DisplayRow::Line(index) if self.source_row(focus, index) == Some(source_row))
|
matches!(*display, DisplayRow::Line(index) if self.source_row(focus, index) == Some(source_row))
|
||||||
@@ -870,14 +923,14 @@ impl DiffState {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn snapshot_lines(&self, focus: Focus) -> Vec<String> {
|
pub(crate) fn snapshot_lines_at(&self, file_index: usize, focus: Focus) -> Vec<String> {
|
||||||
let file = &self.comparison.as_ref().expect("snapshot comparison").files[self.file];
|
let file = &self.comparison.as_ref().expect("snapshot comparison").files[file_index];
|
||||||
let (source, eof, live) = match focus {
|
let (source, eof, live) = match focus {
|
||||||
Focus::Before => (&file.before, file.before_eof, false),
|
Focus::Before => (&file.before, file.before_eof, false),
|
||||||
Focus::After => (
|
Focus::After => (
|
||||||
&file.after,
|
&file.after,
|
||||||
file.after_eof,
|
file.after_eof,
|
||||||
self.live_files.contains(&self.file),
|
self.live_files.contains(&file_index),
|
||||||
),
|
),
|
||||||
Focus::Files => unreachable!("snapshot belongs to a text pane"),
|
Focus::Files => unreachable!("snapshot belongs to a text pane"),
|
||||||
};
|
};
|
||||||
@@ -938,7 +991,7 @@ impl DiffState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh_after(&mut self, lines: &[String], revision: u64) {
|
pub fn refresh_after(&mut self, lines: &[String], revision: u64) {
|
||||||
if !self.after_editable() {
|
if !self.working_after_available() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if self.live_revision == Some(revision) {
|
if self.live_revision == Some(revision) {
|
||||||
@@ -2106,32 +2159,66 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn after_escape_restores_before_query_and_selected_revision_and_cancels_request() {
|
fn selector_escape_closes_each_stage_and_rejects_late_results() {
|
||||||
|
for after in [false, true] {
|
||||||
|
let mut editor = editor();
|
||||||
|
editor
|
||||||
|
.apply(vec![Effect::Replace("unsaved source".into())])
|
||||||
|
.unwrap();
|
||||||
|
let buffer = editor.buffer_id();
|
||||||
|
open(&mut editor);
|
||||||
|
let request = if after {
|
||||||
|
Some(choose_pair(&mut editor))
|
||||||
|
} else {
|
||||||
|
text(&mut editor, "query");
|
||||||
|
None
|
||||||
|
};
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
assert!(editor.diff().is_none());
|
||||||
|
assert_eq!(editor.buffer_id(), buffer);
|
||||||
|
assert_eq!(editor.lines(), ["unsaved source"]);
|
||||||
|
assert!(editor.dirty());
|
||||||
|
if let Some(id) = request {
|
||||||
|
assert!(!editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison()))));
|
||||||
|
assert!(editor.diff().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selector_printable_input_precedes_normal_bindings_but_not_selection_overrides() {
|
||||||
|
for after in [false, true] {
|
||||||
let mut editor = editor();
|
let mut editor = editor();
|
||||||
open(&mut editor);
|
open(&mut editor);
|
||||||
editor.handle(Input::Char('\t'));
|
if after {
|
||||||
text(&mut editor, "topic");
|
|
||||||
editor.handle(Input::Down);
|
|
||||||
editor.handle(Input::Enter);
|
editor.handle(Input::Enter);
|
||||||
|
}
|
||||||
|
editor
|
||||||
|
.bind("normal", "x", Some("core.command".into()))
|
||||||
|
.unwrap();
|
||||||
|
for ch in ":_| x".chars() {
|
||||||
|
editor.handle(Input::Char(ch));
|
||||||
|
assert_eq!(editor.mode(), Mode::Normal);
|
||||||
|
assert_eq!(editor.pane_layout().len(), 1);
|
||||||
|
}
|
||||||
|
assert_eq!(editor.diff().unwrap().query, ":_| x");
|
||||||
|
editor.handle(Input::Ctrl('u'));
|
||||||
|
editor
|
||||||
|
.bind("selection", ":", Some("core.down".into()))
|
||||||
|
.unwrap();
|
||||||
|
editor.handle(Input::Char(':'));
|
||||||
|
assert_eq!(editor.diff().unwrap().selected, 1);
|
||||||
assert!(editor.diff().unwrap().query.is_empty());
|
assert!(editor.diff().unwrap().query.is_empty());
|
||||||
editor.handle(Input::Enter);
|
}
|
||||||
let (id, _) = editor.take_diff_request().unwrap();
|
let mut editor = editor();
|
||||||
|
open(&mut editor);
|
||||||
|
editor.handle(Input::Ctrl('p'));
|
||||||
|
editor.handle(Input::Char(':'));
|
||||||
|
assert_eq!(editor.palette().unwrap().query, ":");
|
||||||
|
assert_eq!(editor.mode(), Mode::Normal);
|
||||||
editor.handle(Input::Escape);
|
editor.handle(Input::Escape);
|
||||||
let state = editor.diff().unwrap();
|
assert!(editor.palette().is_none());
|
||||||
assert_eq!(state.phase, Phase::Before);
|
assert!(editor.diff().is_some());
|
||||||
assert_eq!(state.category, 1);
|
|
||||||
assert_eq!(state.query, "topic");
|
|
||||||
assert_eq!(state.selected, 1);
|
|
||||||
assert!(!state.loading);
|
|
||||||
assert!(!editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison()))));
|
|
||||||
editor.handle(Input::Enter);
|
|
||||||
assert_eq!(editor.diff().unwrap().phase, Phase::After);
|
|
||||||
editor.handle(Input::Escape);
|
|
||||||
editor.handle(Input::Escape);
|
|
||||||
assert_eq!(editor.diff().unwrap().phase, Phase::Before);
|
|
||||||
assert!(editor.diff().unwrap().query.is_empty());
|
|
||||||
editor.execute("diff.close");
|
|
||||||
assert!(editor.diff().is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2560,8 +2647,7 @@ mod tests {
|
|||||||
buffers
|
buffers
|
||||||
);
|
);
|
||||||
editor.handle(Input::Escape);
|
editor.handle(Input::Escape);
|
||||||
assert!(editor.diff().is_some());
|
assert!(editor.diff().is_none());
|
||||||
editor.execute("diff.close");
|
|
||||||
assert_eq!(editor.mode(), Mode::Normal);
|
assert_eq!(editor.mode(), Mode::Normal);
|
||||||
open(&mut editor);
|
open(&mut editor);
|
||||||
let id = choose_pair(&mut editor);
|
let id = choose_pair(&mut editor);
|
||||||
|
|||||||
+3668
-189
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
|||||||
|
mod battery;
|
||||||
|
pub mod chrome;
|
||||||
pub mod clipboard;
|
pub mod clipboard;
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
|
|||||||
+148
-68
@@ -20,7 +20,7 @@ use crate::{
|
|||||||
plugin::{
|
plugin::{
|
||||||
CompletionEdit, CompletionItem, Diagnostic, DiagnosticSeverity, DocumentEvent,
|
CompletionEdit, CompletionItem, Diagnostic, DiagnosticSeverity, DocumentEvent,
|
||||||
DocumentEventKind, DocumentSnapshot, PluginUpdate, SemanticToken, SemanticTokenKind,
|
DocumentEventKind, DocumentSnapshot, PluginUpdate, SemanticToken, SemanticTokenKind,
|
||||||
SemanticTokenModifier, TextPosition, UpdateEffect,
|
SemanticTokenModifier, TextPosition, UpdateEffect, Utf16Range,
|
||||||
},
|
},
|
||||||
snippet,
|
snippet,
|
||||||
};
|
};
|
||||||
@@ -60,6 +60,7 @@ struct Contribution {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum Output {
|
enum Output {
|
||||||
|
Ready(String),
|
||||||
Update(PluginUpdate),
|
Update(PluginUpdate),
|
||||||
Diagnostics {
|
Diagnostics {
|
||||||
snapshot: Arc<DocumentSnapshot>,
|
snapshot: Arc<DocumentSnapshot>,
|
||||||
@@ -77,6 +78,7 @@ struct Worker {
|
|||||||
failure: Receiver<String>,
|
failure: Receiver<String>,
|
||||||
cancelled: Arc<AtomicBool>,
|
cancelled: Arc<AtomicBool>,
|
||||||
retired: bool,
|
retired: bool,
|
||||||
|
name: Option<String>,
|
||||||
thread: Option<thread::JoinHandle<()>>,
|
thread: Option<thread::JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +147,20 @@ impl Lsp {
|
|||||||
!self.configs.is_empty()
|
!self.configs.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ready servers assigned to this document, in configured stack order.
|
||||||
|
pub fn active_server_names(&self, buffer_id: usize) -> impl Iterator<Item = &str> {
|
||||||
|
self.documents
|
||||||
|
.get(&buffer_id)
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|doc| &doc.servers)
|
||||||
|
.filter_map(|key| {
|
||||||
|
self.servers
|
||||||
|
.get(key)
|
||||||
|
.filter(|worker| !worker.retired)
|
||||||
|
.and_then(|worker| worker.name.as_deref())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn notify_document(&mut self, event: &DocumentEvent) -> Result<(), String> {
|
pub fn notify_document(&mut self, event: &DocumentEvent) -> Result<(), String> {
|
||||||
if !self.is_enabled() {
|
if !self.is_enabled() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -334,6 +350,7 @@ impl Lsp {
|
|||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
match output {
|
match output {
|
||||||
|
Output::Ready(name) => worker.name = Some(name),
|
||||||
Output::Update(update) => self.updates.push(update),
|
Output::Update(update) => self.updates.push(update),
|
||||||
Output::Diagnostics { snapshot, items } => {
|
Output::Diagnostics { snapshot, items } => {
|
||||||
let Some(doc) = self.documents.get_mut(&snapshot.id).filter(|doc| {
|
let Some(doc) = self.documents.get_mut(&snapshot.id).filter(|doc| {
|
||||||
@@ -485,6 +502,7 @@ impl Lsp {
|
|||||||
failure,
|
failure,
|
||||||
cancelled,
|
cancelled,
|
||||||
retired: false,
|
retired: false,
|
||||||
|
name: None,
|
||||||
thread,
|
thread,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -552,6 +570,7 @@ impl Lsp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
worker.retired = true;
|
worker.retired = true;
|
||||||
|
worker.name = None;
|
||||||
worker.cancelled.store(true, Ordering::Release);
|
worker.cancelled.store(true, Ordering::Release);
|
||||||
for (id, doc) in documents {
|
for (id, doc) in documents {
|
||||||
if doc.servers.first() == Some(key) {
|
if doc.servers.first() == Some(key) {
|
||||||
@@ -605,8 +624,8 @@ struct SyncOptions {
|
|||||||
|
|
||||||
enum Request {
|
enum Request {
|
||||||
Initialize,
|
Initialize,
|
||||||
Definition(Source),
|
Definition(Source, Arc<DocumentSnapshot>),
|
||||||
Hover(Source),
|
Hover(Source, Arc<DocumentSnapshot>),
|
||||||
Completion(Source, Arc<DocumentSnapshot>),
|
Completion(Source, Arc<DocumentSnapshot>),
|
||||||
SemanticTokens(Arc<DocumentSnapshot>),
|
SemanticTokens(Arc<DocumentSnapshot>),
|
||||||
Shutdown,
|
Shutdown,
|
||||||
@@ -982,8 +1001,8 @@ impl Runtime {
|
|||||||
params["context"] = json!({"triggerKind": 1});
|
params["context"] = json!({"triggerKind": 1});
|
||||||
}
|
}
|
||||||
let request = match kind {
|
let request = match kind {
|
||||||
PositionKind::Definition => Request::Definition(source),
|
PositionKind::Definition => Request::Definition(source, Arc::clone(&doc.snapshot)),
|
||||||
PositionKind::Hover => Request::Hover(source),
|
PositionKind::Hover => Request::Hover(source, Arc::clone(&doc.snapshot)),
|
||||||
PositionKind::Completion => Request::Completion(source, Arc::clone(&doc.snapshot)),
|
PositionKind::Completion => Request::Completion(source, Arc::clone(&doc.snapshot)),
|
||||||
};
|
};
|
||||||
self.request(method, Some(params), request)
|
self.request(method, Some(params), request)
|
||||||
@@ -1134,8 +1153,8 @@ impl Runtime {
|
|||||||
let context = self.requests.remove(&id).ok_or("unexpected LSP response")?;
|
let context = self.requests.remove(&id).ok_or("unexpected LSP response")?;
|
||||||
if object.contains_key("result") == object.contains_key("error") {
|
if object.contains_key("result") == object.contains_key("error") {
|
||||||
return match context {
|
return match context {
|
||||||
Request::Hover(source) => {
|
Request::Hover(source, snapshot) => {
|
||||||
self.hover_result(source, Err("unexpected LSP response".into()))
|
self.hover_result(source, snapshot, Err("unexpected LSP response".into()))
|
||||||
}
|
}
|
||||||
Request::Completion(source, snapshot) => self.completion_result(
|
Request::Completion(source, snapshot) => self.completion_result(
|
||||||
source,
|
source,
|
||||||
@@ -1154,9 +1173,11 @@ impl Runtime {
|
|||||||
.filter(|_| error["code"].as_i64().is_some());
|
.filter(|_| error["code"].as_i64().is_some());
|
||||||
let Some(text) = text else {
|
let Some(text) = text else {
|
||||||
return match context {
|
return match context {
|
||||||
Request::Hover(source) => {
|
Request::Hover(source, snapshot) => self.hover_result(
|
||||||
self.hover_result(source, Err("invalid LSP error response".into()))
|
source,
|
||||||
}
|
snapshot,
|
||||||
|
Err("invalid LSP error response".into()),
|
||||||
|
),
|
||||||
Request::Completion(source, snapshot) => self.completion_result(
|
Request::Completion(source, snapshot) => self.completion_result(
|
||||||
source,
|
source,
|
||||||
snapshot,
|
snapshot,
|
||||||
@@ -1170,11 +1191,12 @@ impl Runtime {
|
|||||||
};
|
};
|
||||||
match context {
|
match context {
|
||||||
Request::Initialize => Err(format!("initialization failed: {text}")),
|
Request::Initialize => Err(format!("initialization failed: {text}")),
|
||||||
Request::Definition(source) => self.emit(message(
|
Request::Definition(source, snapshot) => {
|
||||||
&format!("definition failed: {text}"),
|
self.definition_result(source, snapshot, Err(text.to_owned()))
|
||||||
Some(source.request_id),
|
}
|
||||||
)),
|
Request::Hover(source, snapshot) => {
|
||||||
Request::Hover(source) => self.hover_result(source, Err(text.to_owned())),
|
self.hover_result(source, snapshot, Err(text.to_owned()))
|
||||||
|
}
|
||||||
Request::Completion(source, snapshot) => {
|
Request::Completion(source, snapshot) => {
|
||||||
self.completion_result(source, snapshot, Err(text.to_owned()))
|
self.completion_result(source, snapshot, Err(text.to_owned()))
|
||||||
}
|
}
|
||||||
@@ -1186,8 +1208,12 @@ impl Runtime {
|
|||||||
} else {
|
} else {
|
||||||
match context {
|
match context {
|
||||||
Request::Initialize => self.initialized(&frame["result"]),
|
Request::Initialize => self.initialized(&frame["result"]),
|
||||||
Request::Definition(source) => self.definition_result(source, &frame["result"]),
|
Request::Definition(source, snapshot) => {
|
||||||
Request::Hover(source) => self.hover_result(source, Ok(&frame["result"])),
|
self.definition_result(source, snapshot, Ok(&frame["result"]))
|
||||||
|
}
|
||||||
|
Request::Hover(source, snapshot) => {
|
||||||
|
self.hover_result(source, snapshot, Ok(&frame["result"]))
|
||||||
|
}
|
||||||
Request::Completion(source, snapshot) => {
|
Request::Completion(source, snapshot) => {
|
||||||
self.completion_result(source, snapshot, Ok(&frame["result"]))
|
self.completion_result(source, snapshot, Ok(&frame["result"]))
|
||||||
}
|
}
|
||||||
@@ -1232,6 +1258,18 @@ impl Runtime {
|
|||||||
Some(json!({"settings": self.config.settings})),
|
Some(json!({"settings": self.config.settings})),
|
||||||
)?;
|
)?;
|
||||||
self.ready = true;
|
self.ready = true;
|
||||||
|
let name = result["serverInfo"]["name"]
|
||||||
|
.as_str()
|
||||||
|
.filter(|name| !name.chars().any(char::is_control))
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
Path::new(&self.config.command[0])
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or(&self.config.command[0])
|
||||||
|
});
|
||||||
|
self.send(Output::Ready(clean(name)))?;
|
||||||
let documents = std::mem::take(&mut self.documents);
|
let documents = std::mem::take(&mut self.documents);
|
||||||
for (id, mut doc) in documents {
|
for (id, mut doc) in documents {
|
||||||
self.open(&mut doc)?;
|
self.open(&mut doc)?;
|
||||||
@@ -1363,16 +1401,20 @@ impl Runtime {
|
|||||||
self.emit_diagnostics(&doc.snapshot, diagnostics)
|
self.emit_diagnostics(&doc.snapshot, diagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn definition_result(&self, source: Source, result: &Value) -> Result<(), String> {
|
fn definition_result(
|
||||||
|
&self,
|
||||||
|
source: Source,
|
||||||
|
snapshot: Arc<DocumentSnapshot>,
|
||||||
|
result: Result<&Value, String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
if self
|
if self
|
||||||
.documents
|
.documents
|
||||||
.get(&source.buffer_id)
|
.get(&source.buffer_id)
|
||||||
.is_none_or(|doc| doc.snapshot.revision != source.revision)
|
.is_none_or(|doc| !Arc::ptr_eq(&doc.snapshot, &snapshot))
|
||||||
{
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let result = self.definition_effects(&source, result);
|
match result.and_then(|result| Self::definition_effects(&source, result)) {
|
||||||
match result {
|
|
||||||
Ok(effects) => self.emit(PluginUpdate {
|
Ok(effects) => self.emit(PluginUpdate {
|
||||||
request_id: Some(source.request_id),
|
request_id: Some(source.request_id),
|
||||||
effects,
|
effects,
|
||||||
@@ -1384,11 +1426,16 @@ impl Runtime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hover_result(&self, source: Source, result: Result<&Value, String>) -> Result<(), String> {
|
fn hover_result(
|
||||||
|
&self,
|
||||||
|
source: Source,
|
||||||
|
snapshot: Arc<DocumentSnapshot>,
|
||||||
|
result: Result<&Value, String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
let Some(doc) = self
|
let Some(doc) = self
|
||||||
.documents
|
.documents
|
||||||
.get(&source.buffer_id)
|
.get(&source.buffer_id)
|
||||||
.filter(|doc| doc.snapshot.revision == source.revision)
|
.filter(|doc| Arc::ptr_eq(&doc.snapshot, &snapshot))
|
||||||
else {
|
else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
@@ -1409,11 +1456,7 @@ impl Runtime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn definition_effects(
|
fn definition_effects(source: &Source, result: &Value) -> Result<Vec<UpdateEffect>, String> {
|
||||||
&self,
|
|
||||||
source: &Source,
|
|
||||||
result: &Value,
|
|
||||||
) -> Result<Vec<UpdateEffect>, String> {
|
|
||||||
let (location, count) = match result {
|
let (location, count) = match result {
|
||||||
Value::Null => return Ok(message("no definition found", None).effects),
|
Value::Null => return Ok(message("no definition found", None).effects),
|
||||||
Value::Array(locations) if locations.is_empty() => {
|
Value::Array(locations) if locations.is_empty() => {
|
||||||
@@ -1431,53 +1474,25 @@ impl Runtime {
|
|||||||
.as_str()
|
.as_str()
|
||||||
.ok_or("invalid definition URI")?,
|
.ok_or("invalid definition URI")?,
|
||||||
)?;
|
)?;
|
||||||
let identity = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
|
// Keep the server's document identity and UTF-16 coordinates together.
|
||||||
let cached = self.snapshots.values().find(|doc| {
|
// The editor resolves historical/working text, including lazily loaded
|
||||||
doc.path.as_ref().is_some_and(|candidate| {
|
// comparison files, before converting the target position.
|
||||||
let candidate = absolute(Path::new(candidate), &self.root);
|
let target_range = link
|
||||||
std::fs::canonicalize(&candidate).unwrap_or(candidate) == identity
|
.then(|| range_coordinates(&location["targetRange"]))
|
||||||
})
|
.transpose()?;
|
||||||
});
|
let range = range_coordinates(
|
||||||
let disk;
|
|
||||||
let text = if let Some(doc) = cached {
|
|
||||||
&doc.text
|
|
||||||
} else {
|
|
||||||
if !std::fs::metadata(&path)
|
|
||||||
.map_err(|error| error.to_string())?
|
|
||||||
.is_file()
|
|
||||||
{
|
|
||||||
return Err("definition target must be a regular file".into());
|
|
||||||
}
|
|
||||||
let mut bytes = Vec::new();
|
|
||||||
std::fs::File::open(&path)
|
|
||||||
.map_err(|error| error.to_string())?
|
|
||||||
.take((MAX_BODY + 1) as u64)
|
|
||||||
.read_to_end(&mut bytes)
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
if bytes.len() > MAX_BODY {
|
|
||||||
return Err("definition target exceeds 4 MiB".into());
|
|
||||||
}
|
|
||||||
disk = String::from_utf8(bytes).map_err(|_| "definition target is not UTF-8")?;
|
|
||||||
&disk
|
|
||||||
};
|
|
||||||
let text = normalized(text);
|
|
||||||
if link {
|
|
||||||
range(&text, &location["targetRange"])?;
|
|
||||||
}
|
|
||||||
let (start, _) = range(
|
|
||||||
&text,
|
|
||||||
&location[if link {
|
&location[if link {
|
||||||
"targetSelectionRange"
|
"targetSelectionRange"
|
||||||
} else {
|
} else {
|
||||||
"range"
|
"range"
|
||||||
}],
|
}],
|
||||||
)?;
|
)?;
|
||||||
let mut effects = vec![UpdateEffect::Navigate {
|
let mut effects = vec![UpdateEffect::NavigateUtf16 {
|
||||||
buffer_id: source.buffer_id,
|
buffer_id: source.buffer_id,
|
||||||
revision: source.revision,
|
revision: source.revision,
|
||||||
path,
|
path,
|
||||||
row: start.row,
|
range,
|
||||||
column: start.column,
|
target_range,
|
||||||
}];
|
}];
|
||||||
if count > 1 {
|
if count > 1 {
|
||||||
effects.push(UpdateEffect::Message {
|
effects.push(UpdateEffect::Message {
|
||||||
@@ -2856,10 +2871,17 @@ fn position(text: &str, value: &Value, round_up: bool) -> Result<TextPosition, S
|
|||||||
Ok(TextPosition { row, column })
|
Ok(TextPosition { row, column })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn range(text: &str, value: &Value) -> Result<(TextPosition, TextPosition), String> {
|
fn range_coordinates(value: &Value) -> Result<Utf16Range, String> {
|
||||||
if coordinates(&value["start"])? > coordinates(&value["end"])? {
|
let start = coordinates(&value["start"])?;
|
||||||
|
let end = coordinates(&value["end"])?;
|
||||||
|
if start > end {
|
||||||
return Err("reversed LSP range".into());
|
return Err("reversed LSP range".into());
|
||||||
}
|
}
|
||||||
|
Ok(Utf16Range { start, end })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn range(text: &str, value: &Value) -> Result<(TextPosition, TextPosition), String> {
|
||||||
|
range_coordinates(value)?;
|
||||||
Ok((
|
Ok((
|
||||||
position(text, &value["start"], false)?,
|
position(text, &value["start"], false)?,
|
||||||
position(text, &value["end"], true)?,
|
position(text, &value["end"], true)?,
|
||||||
@@ -2971,6 +2993,64 @@ fn uri_path(uri: &str) -> Result<PathBuf, String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_definitions_preserve_snapshot_identity_and_utf16_until_navigation() {
|
||||||
|
let source = Source {
|
||||||
|
buffer_id: 7,
|
||||||
|
revision: 3,
|
||||||
|
row: 0,
|
||||||
|
column: 0,
|
||||||
|
request_id: 11,
|
||||||
|
};
|
||||||
|
let path = Path::new("/vamp-immutable-snapshot/Before/renamed/λ.rs");
|
||||||
|
let span = json!({
|
||||||
|
"start": {"line": 4, "character": 5},
|
||||||
|
"end": {"line": 4, "character": 8}
|
||||||
|
});
|
||||||
|
for location in [
|
||||||
|
json!({"uri": file_uri(path).unwrap(), "range": span}),
|
||||||
|
json!({"targetUri": file_uri(path).unwrap(),
|
||||||
|
"targetRange": span, "targetSelectionRange": span}),
|
||||||
|
] {
|
||||||
|
let effects = Runtime::definition_effects(&source, &location).unwrap();
|
||||||
|
let expected_target_range = location.get("targetUri").map(|_| Utf16Range {
|
||||||
|
start: (4, 5),
|
||||||
|
end: (4, 8),
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
effects.as_slice(),
|
||||||
|
[UpdateEffect::NavigateUtf16 {
|
||||||
|
buffer_id: 7, revision: 3, path: target,
|
||||||
|
range: Utf16Range { start: (4, 5), end: (4, 8) }, target_range,
|
||||||
|
}] if target == path && *target_range == expected_target_range
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for invalid in [
|
||||||
|
json!({"start": {"line": 4, "character": 8},
|
||||||
|
"end": {"line": 4, "character": 5}}),
|
||||||
|
json!({"start": {"line": -1, "character": 0},
|
||||||
|
"end": {"line": 0, "character": 0}}),
|
||||||
|
] {
|
||||||
|
for location in [
|
||||||
|
json!({"uri": file_uri(path).unwrap(), "range": invalid}),
|
||||||
|
json!({"targetUri": file_uri(path).unwrap(),
|
||||||
|
"targetRange": invalid, "targetSelectionRange": span}),
|
||||||
|
json!({"targetUri": file_uri(path).unwrap(),
|
||||||
|
"targetRange": span, "targetSelectionRange": invalid}),
|
||||||
|
] {
|
||||||
|
assert!(Runtime::definition_effects(&source, &location).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<UpdateEffect>(json!({
|
||||||
|
"type": "navigate_utf16", "buffer_id": 7, "revision": 3,
|
||||||
|
"path": path, "range": {"start": [4,5], "end": [4,8]},
|
||||||
|
"target_range": null
|
||||||
|
}))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn semantic_decoder_validates_raw_coordinates_before_grapheme_rounding() {
|
fn semantic_decoder_validates_raw_coordinates_before_grapheme_rounding() {
|
||||||
let options = semantic_options(Some(&json!({
|
let options = semantic_options(Some(&json!({
|
||||||
|
|||||||
+6
-2
@@ -21,6 +21,8 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
|
|||||||
gf follows Markdown file links/reference links and heading fragments (no LSP)
|
gf follows Markdown file links/reference links and heading fragments (no LSP)
|
||||||
Normal/Visual: gt first line, gb last line, at first nonblank; Esc cancels g
|
Normal/Visual: gt first line, gb last line, at first nonblank; Esc cancels g
|
||||||
Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported)
|
Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported)
|
||||||
|
_ splits above/below, | left/right; Option/Alt+h/j/k/l focuses neighboring panes
|
||||||
|
Ctrl+Option/Alt+h/l previous/next tab; :tab.new opens current buffer in another tab
|
||||||
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
|
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
|
||||||
u undo, U redo, : command, / search, n/N next/previous match
|
u undo, U redo, : command, / search, n/N next/previous match
|
||||||
dd deletes lines; cc changes lines and enters Insert; counts: 3dd, 2cc; Esc cancels prefix
|
dd deletes lines; cc changes lines and enters Insert; counts: 3dd, 2cc; Esc cancels prefix
|
||||||
@@ -42,14 +44,16 @@ Aliases: w=save, q=quit, q!=force_quit, wq=save_and_quit
|
|||||||
Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffer_delete [N], :buffer_force_delete [N]
|
Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffer_delete [N], :buffer_force_delete [N]
|
||||||
Aliases: bnext, bprev, bdelete, bdelete!; arguments and safety rules are unchanged
|
Aliases: bnext, bprev, bdelete, bdelete!; arguments and safety rules are unchanged
|
||||||
:quit refuses any dirty buffer; :save_and_quit saves active and quits only if all are clean
|
:quit refuses any dirty buffer; :save_and_quit saves active and quits only if all are clean
|
||||||
|
Tabs: :tab.new, :tab.previous, :tab.next, :tab.close, :tab.force_close
|
||||||
|
Tabs retain pane layouts and independent views; close protects exclusively displayed unsaved edits
|
||||||
Markdown links: :follow_link (primary caret; local files only)
|
Markdown links: :follow_link (primary caret; local files only)
|
||||||
Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers
|
Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers
|
||||||
Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus
|
Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus
|
||||||
All text panes use native motions, selections and copy; read-only panes reject edits/saves
|
All text panes use native motions, selections and copy; read-only panes reject edits/saves
|
||||||
Switching Before/After preserves the aligned cursor row and display column
|
Switching Before/After preserves the aligned cursor row and display column
|
||||||
Type to search IDs/refs; navigation skips omitted lines without expanding excerpts
|
Selector queries own printable keys, including :; text navigation skips omitted lines
|
||||||
Normal [/] explicitly expands context; Insert brackets remain literal text
|
Normal [/] explicitly expands context; Insert brackets remain literal text
|
||||||
:diff.close exits explicitly; Esc cancels modes/prompts/filtering without closing the diff
|
Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly
|
||||||
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
||||||
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
|
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,13 @@ pub struct CompletionItem {
|
|||||||
pub snippet: Option<CompletionSnippet>,
|
pub snippet: Option<CompletionSnippet>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Native LSP range; each coordinate is (zero-based row, UTF-16 column).
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Utf16Range {
|
||||||
|
pub start: (usize, usize),
|
||||||
|
pub end: (usize, usize),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
pub enum UpdateEffect {
|
pub enum UpdateEffect {
|
||||||
@@ -162,6 +169,15 @@ pub enum UpdateEffect {
|
|||||||
row: usize,
|
row: usize,
|
||||||
column: usize,
|
column: usize,
|
||||||
},
|
},
|
||||||
|
/// Native LSP targets retain UTF-16 coordinates until their source text is resolved.
|
||||||
|
#[serde(skip_deserializing)]
|
||||||
|
NavigateUtf16 {
|
||||||
|
buffer_id: usize,
|
||||||
|
revision: u64,
|
||||||
|
path: PathBuf,
|
||||||
|
range: Utf16Range,
|
||||||
|
target_range: Option<Utf16Range>,
|
||||||
|
},
|
||||||
Hover {
|
Hover {
|
||||||
buffer_id: usize,
|
buffer_id: usize,
|
||||||
revision: u64,
|
revision: u64,
|
||||||
@@ -176,6 +192,10 @@ pub enum UpdateEffect {
|
|||||||
Message {
|
Message {
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
|
Label {
|
||||||
|
name: String,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
+1
-1
@@ -407,7 +407,7 @@ impl Repository {
|
|||||||
let target = RevisionTarget::Commit(id.clone());
|
let target = RevisionTarget::Commit(id.clone());
|
||||||
entries.push(Revision {
|
entries.push(Revision {
|
||||||
target: target.clone(),
|
target: target.clone(),
|
||||||
label: format!("{change} {id}"),
|
label: change.clone(),
|
||||||
description: description.trim_end_matches('\n').into(),
|
description: description.trim_end_matches('\n').into(),
|
||||||
category: Category::Revisions,
|
category: Category::Revisions,
|
||||||
});
|
});
|
||||||
|
|||||||
+261
-8
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::chrome::{ChromeLine, PopupConfig};
|
||||||
use crate::editor::{Context, Effect, Feature};
|
use crate::editor::{Context, Effect, Feature};
|
||||||
use crate::plugin::{DocumentEvent, PluginEvent, PluginUpdate, UpdateEffect};
|
use crate::plugin::{DocumentEvent, PluginEvent, PluginUpdate, UpdateEffect};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -8,7 +9,10 @@ use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender};
|
|||||||
use std::sync::{Arc, OnceLock};
|
use std::sync::{Arc, OnceLock};
|
||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use std::{collections::HashSet, env};
|
use std::{
|
||||||
|
collections::{BTreeMap, HashSet},
|
||||||
|
env,
|
||||||
|
};
|
||||||
|
|
||||||
const RUNNER: &str = concat!(
|
const RUNNER: &str = concat!(
|
||||||
include_str!("ruby_values.rb"),
|
include_str!("ruby_values.rb"),
|
||||||
@@ -34,6 +38,10 @@ pub struct Config {
|
|||||||
pub file_picker_view: String,
|
pub file_picker_view: String,
|
||||||
pub file_explorer_view: String,
|
pub file_explorer_view: String,
|
||||||
pub clipboard_mode: String,
|
pub clipboard_mode: String,
|
||||||
|
pub top_bar: ChromeLine,
|
||||||
|
pub bottom_bar: ChromeLine,
|
||||||
|
pub popup: PopupConfig,
|
||||||
|
pub labels: BTreeMap<String, String>,
|
||||||
pub diff_context_lines: usize,
|
pub diff_context_lines: usize,
|
||||||
pub diff_expand_lines: usize,
|
pub diff_expand_lines: usize,
|
||||||
}
|
}
|
||||||
@@ -70,6 +78,10 @@ impl Default for Config {
|
|||||||
file_picker_view: "tree".into(),
|
file_picker_view: "tree".into(),
|
||||||
file_explorer_view: "tree".into(),
|
file_explorer_view: "tree".into(),
|
||||||
clipboard_mode: "system".into(),
|
clipboard_mode: "system".into(),
|
||||||
|
top_bar: ChromeLine::top(),
|
||||||
|
bottom_bar: ChromeLine::bottom(),
|
||||||
|
popup: PopupConfig::default(),
|
||||||
|
labels: BTreeMap::new(),
|
||||||
diff_context_lines: 10,
|
diff_context_lines: 10,
|
||||||
diff_expand_lines: 15,
|
diff_expand_lines: 15,
|
||||||
}
|
}
|
||||||
@@ -92,6 +104,10 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
|||||||
file_picker_view,
|
file_picker_view,
|
||||||
file_explorer_view,
|
file_explorer_view,
|
||||||
clipboard_mode,
|
clipboard_mode,
|
||||||
|
top_bar,
|
||||||
|
bottom_bar,
|
||||||
|
popup,
|
||||||
|
labels,
|
||||||
diff_context_lines,
|
diff_context_lines,
|
||||||
diff_expand_lines,
|
diff_expand_lines,
|
||||||
..
|
..
|
||||||
@@ -116,6 +132,10 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
|||||||
config.file_picker_view = file_picker_view;
|
config.file_picker_view = file_picker_view;
|
||||||
config.file_explorer_view = file_explorer_view;
|
config.file_explorer_view = file_explorer_view;
|
||||||
config.clipboard_mode = clipboard_mode;
|
config.clipboard_mode = clipboard_mode;
|
||||||
|
config.top_bar = top_bar;
|
||||||
|
config.bottom_bar = bottom_bar;
|
||||||
|
config.popup = popup;
|
||||||
|
config.labels = labels;
|
||||||
config.diff_context_lines = diff_context_lines;
|
config.diff_context_lines = diff_context_lines;
|
||||||
config.diff_expand_lines = diff_expand_lines;
|
config.diff_expand_lines = diff_expand_lines;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
@@ -346,6 +366,7 @@ fn convert_effects(effects: Vec<WireEffect>) -> Vec<Effect> {
|
|||||||
WireEffect::Insert { text } => Effect::Insert(text),
|
WireEffect::Insert { text } => Effect::Insert(text),
|
||||||
WireEffect::Replace { text } => Effect::Replace(text),
|
WireEffect::Replace { text } => Effect::Replace(text),
|
||||||
WireEffect::Message { text } => Effect::Message(text),
|
WireEffect::Message { text } => Effect::Message(text),
|
||||||
|
WireEffect::Label { name, text } => Effect::Label { name, text },
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -433,6 +454,13 @@ enum Response {
|
|||||||
file_picker_view: String,
|
file_picker_view: String,
|
||||||
file_explorer_view: String,
|
file_explorer_view: String,
|
||||||
clipboard_mode: String,
|
clipboard_mode: String,
|
||||||
|
#[serde(deserialize_with = "crate::chrome::top")]
|
||||||
|
top_bar: ChromeLine,
|
||||||
|
#[serde(deserialize_with = "crate::chrome::bottom")]
|
||||||
|
bottom_bar: ChromeLine,
|
||||||
|
popup: PopupConfig,
|
||||||
|
#[serde(deserialize_with = "crate::chrome::labels")]
|
||||||
|
labels: BTreeMap<String, String>,
|
||||||
#[serde(deserialize_with = "diff_context_lines")]
|
#[serde(deserialize_with = "diff_context_lines")]
|
||||||
diff_context_lines: usize,
|
diff_context_lines: usize,
|
||||||
#[serde(deserialize_with = "diff_expand_lines")]
|
#[serde(deserialize_with = "diff_expand_lines")]
|
||||||
@@ -494,6 +522,8 @@ enum WireEffect {
|
|||||||
Replace { text: String },
|
Replace { text: String },
|
||||||
#[serde(rename = "message")]
|
#[serde(rename = "message")]
|
||||||
Message { text: String },
|
Message { text: String },
|
||||||
|
#[serde(rename = "label")]
|
||||||
|
Label { name: String, text: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
struct OutboundFrame {
|
struct OutboundFrame {
|
||||||
@@ -882,6 +912,8 @@ Vamp.command "greet" do |editor, args|
|
|||||||
editor.insert "Hello #{args}!"
|
editor.insert "Hello #{args}!"
|
||||||
editor.replace editor.text
|
editor.replace editor.text
|
||||||
editor.message "Greeting inserted"
|
editor.message "Greeting inserted"
|
||||||
|
editor.label :battery, "界 e\u0301 👩🚀\t\n\e"
|
||||||
|
editor.label "battery", ""
|
||||||
end
|
end
|
||||||
"#);
|
"#);
|
||||||
assert_eq!(feature.name, "greeting");
|
assert_eq!(feature.name, "greeting");
|
||||||
@@ -892,8 +924,10 @@ end
|
|||||||
for _ in 0..2 {
|
for _ in 0..2 {
|
||||||
let effects = plugin.invoke("greet", "friend", &context()).unwrap();
|
let effects = plugin.invoke("greet", "friend", &context()).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(&effects[..], [Effect::Insert(text), Effect::Replace(replacement), Effect::Message(message)]
|
matches!(&effects[..], [Effect::Insert(text), Effect::Replace(replacement), Effect::Message(message), Effect::Label { name, text: label }, Effect::Label { name: cleared_name, text: cleared }]
|
||||||
if text == "Hello friend!" && replacement == "α\nhello" && message == "Greeting inserted")
|
if text == "Hello friend!" && replacement == "α\nhello" && message == "Greeting inserted"
|
||||||
|
&& name == "battery" && cleared_name == "battery"
|
||||||
|
&& label == "界 e\u{301} 👩🚀\t\n\u{1b}" && cleared.is_empty())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
plugin.disable();
|
plugin.disable();
|
||||||
@@ -936,7 +970,7 @@ end
|
|||||||
config.bind "D", config.core.buffer_delete
|
config.bind "D", config.core.buffer_delete
|
||||||
config.bind "Z", config.core.buffer_force_delete
|
config.bind "Z", config.core.buffer_force_delete
|
||||||
config.bind "R", config.core.diff
|
config.bind "R", config.core.diff
|
||||||
config.bind "T", config.core.diff.close
|
config.bind "ctrl+t", config.core.diff.close
|
||||||
config.bind "H", config.core.word_wrap.toggle
|
config.bind "H", config.core.word_wrap.toggle
|
||||||
raise "mutable diff actions" unless config.core.frozen? && config.core.diff.frozen? &&
|
raise "mutable diff actions" unless config.core.frozen? && config.core.diff.frozen? &&
|
||||||
config.core.diff.close.frozen? && config.core.diff.name.frozen? &&
|
config.core.diff.close.frozen? && config.core.diff.name.frozen? &&
|
||||||
@@ -953,8 +987,10 @@ end"#,
|
|||||||
assert!(!review.editor().word_wrap());
|
assert!(!review.editor().word_wrap());
|
||||||
review.handle(Input::Char('R'));
|
review.handle(Input::Char('R'));
|
||||||
review.handle(Input::Escape);
|
review.handle(Input::Escape);
|
||||||
|
assert!(review.editor().diff().is_none());
|
||||||
|
review.handle(Input::Char('R'));
|
||||||
assert!(review.editor().diff().is_some());
|
assert!(review.editor().diff().is_some());
|
||||||
review.handle(Input::Char('T'));
|
review.handle(Input::Ctrl('t'));
|
||||||
assert!(review.editor().diff().is_none());
|
assert!(review.editor().diff().is_none());
|
||||||
for (key, dirty, quit, saved) in [
|
for (key, dirty, quit, saved) in [
|
||||||
('Q', false, true, false),
|
('Q', false, true, false),
|
||||||
@@ -1152,14 +1188,205 @@ end
|
|||||||
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
||||||
}
|
}
|
||||||
for frame in [
|
for frame in [
|
||||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","diff_context_lines":10,"diff_expand_lines":15}"#,
|
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15}"#,
|
||||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","diff_context_lines":10,"diff_expand_lines":15}"#,
|
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15}"#,
|
||||||
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","diff_context_lines":10,"diff_expand_lines":15}"#,
|
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15}"#,
|
||||||
] {
|
] {
|
||||||
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chrome_configuration_normalizes_ruby_values_and_retains_unspecified_defaults() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
r#"
|
||||||
|
Vamp.configure do |config|
|
||||||
|
config.bar.top = {left: [:tabs], center: ["workspace"], right: [:battery],
|
||||||
|
tabs: {title: "T{index}:{path}{modified}", padding: 0, first_leading_edge: ""},
|
||||||
|
battery: {leading_edge: "界", trailing_edge: ""}}
|
||||||
|
config.bar.bottom = {"left" => [], center: [:workspace]}
|
||||||
|
config.labels = {battery: "bat 38%", "workspace" => "界 e\u0301 👩🚀\t\n\e"}
|
||||||
|
end
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let loaded = load_config(&path).unwrap();
|
||||||
|
assert_eq!(loaded.top_bar.left, ["tabs"]);
|
||||||
|
assert_eq!(loaded.top_bar.center, ["workspace"]);
|
||||||
|
assert_eq!(loaded.top_bar.right, ["battery"]);
|
||||||
|
assert_eq!(
|
||||||
|
loaded.top_bar.segment("tabs").title,
|
||||||
|
"T{index}:{path}{modified}"
|
||||||
|
);
|
||||||
|
assert_eq!(loaded.top_bar.segment("tabs").padding, 0);
|
||||||
|
assert_eq!(loaded.top_bar.segment("tabs").trailing_edge, "▓▒░");
|
||||||
|
assert_eq!(loaded.top_bar.segment("battery").leading_edge, "界");
|
||||||
|
assert_eq!(
|
||||||
|
loaded.top_bar.segment("workspace"),
|
||||||
|
&crate::chrome::SegmentConfig::default()
|
||||||
|
);
|
||||||
|
assert!(loaded.bottom_bar.left.is_empty());
|
||||||
|
assert_eq!(loaded.labels["battery"], "bat 38%");
|
||||||
|
assert_eq!(loaded.labels["workspace"], "界 e\u{301} 👩🚀\t\n\u{1b}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn popup_configuration_accepts_ruby_symbol_and_string_anchors_independently() {
|
||||||
|
use crate::chrome::PopupAnchor;
|
||||||
|
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let anchors = [
|
||||||
|
("top_left", PopupAnchor::TopLeft),
|
||||||
|
("top_center", PopupAnchor::TopCenter),
|
||||||
|
("top_right", PopupAnchor::TopRight),
|
||||||
|
("center_left", PopupAnchor::CenterLeft),
|
||||||
|
("center", PopupAnchor::Center),
|
||||||
|
("center_right", PopupAnchor::CenterRight),
|
||||||
|
("bottom_left", PopupAnchor::BottomLeft),
|
||||||
|
("bottom_center", PopupAnchor::BottomCenter),
|
||||||
|
("bottom_right", PopupAnchor::BottomRight),
|
||||||
|
];
|
||||||
|
for (index, (command_name, command_anchor)) in anchors.iter().enumerate() {
|
||||||
|
let (search_name, search_anchor) = anchors[(index + 1) % anchors.len()];
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
&format!(
|
||||||
|
"Vamp.configure do |config|\n\
|
||||||
|
config.popup.command = {{anchor: :{command_name}}}\n\
|
||||||
|
config.popup.search = {{'anchor' => '{search_name}'}}\n\
|
||||||
|
end"
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let loaded = load_config(&path).unwrap();
|
||||||
|
assert_eq!(loaded.popup.command.anchor, *command_anchor);
|
||||||
|
assert_eq!(loaded.popup.search.anchor, search_anchor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn popup_configuration_rejects_unknown_options_anchors_callbacks_and_types() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
for (value, expected) in [
|
||||||
|
("[]", "must be a Hash"),
|
||||||
|
("nil", "must be a Hash"),
|
||||||
|
("7", "must be a Hash"),
|
||||||
|
("{width: 72}", "unknown popup option"),
|
||||||
|
("{callback: -> { 'center' }}", "unknown popup option"),
|
||||||
|
("{anchor: :middle}", "unknown popup anchor"),
|
||||||
|
("{anchor: 'TopLeft'}", "unknown popup anchor"),
|
||||||
|
("{anchor: nil}", "must be a String"),
|
||||||
|
("{anchor: 7}", "must be a String"),
|
||||||
|
("{anchor: true}", "must be a String"),
|
||||||
|
("{anchor: []}", "must be a String"),
|
||||||
|
("{anchor: {}}", "must be a String"),
|
||||||
|
("{anchor: -> { :center }}", "must be a String"),
|
||||||
|
(
|
||||||
|
"{anchor: :center, 'anchor' => :top_left}",
|
||||||
|
"duplicate chrome key",
|
||||||
|
),
|
||||||
|
("{7 => :center}", "chrome key must be a String"),
|
||||||
|
] {
|
||||||
|
for mode in ["command", "search"] {
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
&format!("Vamp.configure {{ |config| config.popup.{mode} = {value} }}"),
|
||||||
|
);
|
||||||
|
let error = load_config(&path).unwrap_err();
|
||||||
|
assert!(error.contains(expected), "{mode}={value}: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let path = fixture.file("config.rb", "Vamp.configure { |config| config.popup = {} }");
|
||||||
|
assert!(load_config(&path).unwrap_err().contains("undefined method"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bar_namespace_is_stable_and_accepts_swapped_builtins() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
r#"
|
||||||
|
Vamp.configure do |config|
|
||||||
|
bar = config.bar
|
||||||
|
raise "bar namespace must be stable" unless bar.equal?(config.bar)
|
||||||
|
bar.top = {left: [:mode, :language], right: [], mode: {padding: 0}}
|
||||||
|
config.bar.bottom = {left: [:tabs], right: [], tabs: {title: "{name}{modified}"}}
|
||||||
|
end
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let loaded = load_config(&path).unwrap();
|
||||||
|
assert_eq!(loaded.top_bar.left, ["mode", "language"]);
|
||||||
|
assert_eq!(loaded.top_bar.segment("mode").padding, 0);
|
||||||
|
assert_eq!(loaded.top_bar.segment("language").padding, 1);
|
||||||
|
assert_eq!(loaded.bottom_bar.left, ["tabs"]);
|
||||||
|
assert_eq!(loaded.bottom_bar.segment("tabs").title, "{name}{modified}");
|
||||||
|
|
||||||
|
for setter in ["bar", "tabline", "statusline"] {
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
&format!("Vamp.configure {{ |config| config.{setter} = {{}} }}"),
|
||||||
|
);
|
||||||
|
let error = load_config(&path).unwrap_err();
|
||||||
|
assert!(error.contains("undefined method"), "{setter}: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chrome_configuration_rejects_invalid_types_names_and_templates() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
for (setting, value, expected) in [
|
||||||
|
("bar.top", "[]", "must be a Hash"),
|
||||||
|
("bar.bottom", "[]", "must be a Hash"),
|
||||||
|
("bar.top", "{left: :tabs}", "must be an Array"),
|
||||||
|
("bar.top", "{left: [7]}", "must be a String"),
|
||||||
|
("bar.top", "{left: [:bad_name!]}", "must match"),
|
||||||
|
(
|
||||||
|
"bar.bottom",
|
||||||
|
"{tabs: {title: '{unknown}'}}",
|
||||||
|
"unknown title placeholder",
|
||||||
|
),
|
||||||
|
("bar.top", r#"{mode: {leading_edge: "\n"}}"#, "single-line"),
|
||||||
|
("bar.top", "{language: {padding: 65}}", "between 0 and 64"),
|
||||||
|
("bar.bottom", "{tabs: {padding: '1'}}", "must be an Integer"),
|
||||||
|
("bar.top", "{mode: {title: nil}}", "must be a String"),
|
||||||
|
(
|
||||||
|
"bar.bottom",
|
||||||
|
"{tabs: {color: 'red'}}",
|
||||||
|
"unknown chrome style option",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"bar.top",
|
||||||
|
"{left: [], 'left' => []}",
|
||||||
|
"duplicate chrome key",
|
||||||
|
),
|
||||||
|
("labels", "[]", "must be a Hash"),
|
||||||
|
("labels", "{battery: 7}", "label text must be a String"),
|
||||||
|
("labels", "{'bad-name' => ''}", "must match"),
|
||||||
|
] {
|
||||||
|
let path = fixture.file(
|
||||||
|
"config.rb",
|
||||||
|
&format!("Vamp.configure {{ |config| config.{setting} = {value} }}"),
|
||||||
|
);
|
||||||
|
let error = load_config(&path).unwrap_err();
|
||||||
|
assert!(error.contains(expected), "{setting}={value}: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn label_wire_effects_require_name_and_string_text() {
|
||||||
|
for frame in [
|
||||||
|
serde_json::json!({"type": "label", "text": ""}),
|
||||||
|
serde_json::json!({"type": "label", "name": 7, "text": ""}),
|
||||||
|
serde_json::json!({"type": "label", "name": "battery"}),
|
||||||
|
serde_json::json!({"type": "label", "name": "battery", "text": null}),
|
||||||
|
serde_json::json!({"type": "label", "name": "battery", "text": 7}),
|
||||||
|
serde_json::json!({"type": "label", "name": "battery", "text": "", "extra": true}),
|
||||||
|
] {
|
||||||
|
assert!(serde_json::from_value::<WireEffect>(frame.clone()).is_err());
|
||||||
|
assert!(serde_json::from_value::<UpdateEffect>(frame).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn config_diff_excerpt_counts_accept_custom_values_and_reject_invalid_values() {
|
fn config_diff_excerpt_counts_accept_custom_values_and_reject_invalid_values() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
@@ -1180,6 +1407,8 @@ end
|
|||||||
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
||||||
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||||||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||||||
|
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
||||||
|
"popup": {"command": {}, "search": {}},
|
||||||
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7
|
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7
|
||||||
});
|
});
|
||||||
for setting in ["diff_context_lines", "diff_expand_lines"] {
|
for setting in ["diff_context_lines", "diff_expand_lines"] {
|
||||||
@@ -1592,6 +1821,8 @@ end"#,
|
|||||||
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
"version": 1, "type": "config", "plugins": [], "lsp": {},
|
||||||
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
"bindings": [], "leader": "space", "file_explorer_side": "right",
|
||||||
"file_picker_view": "tree", "file_explorer_view": "tree",
|
"file_picker_view": "tree", "file_explorer_view": "tree",
|
||||||
|
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
||||||
|
"popup": {"command": {}, "search": {}},
|
||||||
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15
|
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15
|
||||||
});
|
});
|
||||||
let Response::Config { lsp, .. } =
|
let Response::Config { lsp, .. } =
|
||||||
@@ -1674,6 +1905,28 @@ end"#,
|
|||||||
for mutation in [
|
for mutation in [
|
||||||
"return '['",
|
"return '['",
|
||||||
"frame.delete('lsp')",
|
"frame.delete('lsp')",
|
||||||
|
"frame.delete('top_bar')",
|
||||||
|
"frame.delete('bottom_bar')",
|
||||||
|
"frame.delete('labels')",
|
||||||
|
"frame.delete('popup')",
|
||||||
|
"frame['popup'] = nil",
|
||||||
|
"frame['popup'] = []",
|
||||||
|
"frame['popup'] = {'selector' => {}}",
|
||||||
|
"frame['popup'] = {'command' => nil}",
|
||||||
|
"frame['popup'] = {'search' => 7}",
|
||||||
|
"frame['popup'] = {'command' => {'anchor' => nil}}",
|
||||||
|
"frame['popup'] = {'search' => {'anchor' => 'middle'}}",
|
||||||
|
"frame['popup'] = {'command' => {'width' => 72}}",
|
||||||
|
"frame['top_bar'] = 7",
|
||||||
|
"frame['bottom_bar'] = []",
|
||||||
|
"frame['labels'] = {'battery' => 7}",
|
||||||
|
"frame['bottom_bar'] = {'tabs' => {'title' => '{unknown}'}}",
|
||||||
|
"frame['top_bar'] = {'mode' => {'leading_edge' => \"\\n\"}}",
|
||||||
|
"frame['top_bar'] = {'left' => nil}",
|
||||||
|
"frame['bottom_bar'] = {'left' => ['bad-name']}",
|
||||||
|
"frame['bottom_bar'] = {'tabs' => {'padding' => '1'}}",
|
||||||
|
"frame['top_bar'] = {'language' => {'color' => 'red'}}",
|
||||||
|
"frame['labels'] = {'bad-name' => ''}",
|
||||||
"frame['leader'] = 7",
|
"frame['leader'] = 7",
|
||||||
"frame['unexpected'] = true",
|
"frame['unexpected'] = true",
|
||||||
] {
|
] {
|
||||||
|
|||||||
+149
-6
@@ -1,11 +1,131 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Vamp
|
module Vamp
|
||||||
|
# @param name [String, Symbol]
|
||||||
|
# @return [String]
|
||||||
|
def self.chrome_name!(name)
|
||||||
|
name = name.to_s if name.is_a?(Symbol)
|
||||||
|
name = string!(name, "label/segment name")
|
||||||
|
unless /\A[A-Za-z_][A-Za-z0-9_]*\z/.match?(name)
|
||||||
|
raise ArgumentError, "label/segment name must match [A-Za-z_][A-Za-z0-9_]*"
|
||||||
|
end
|
||||||
|
name
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param value [Hash{String, Symbol => Object}]
|
||||||
|
# @return [Hash{String => Object}]
|
||||||
|
def self.chrome_hash!(value)
|
||||||
|
raise TypeError, "chrome options must be a Hash" unless value.is_a?(Hash)
|
||||||
|
value.each_with_object({}) do |(key, item), result|
|
||||||
|
key = key.to_s if key.is_a?(Symbol)
|
||||||
|
key = string!(key, "chrome key")
|
||||||
|
raise ArgumentError, "duplicate chrome key: #{key}" if result.key?(key)
|
||||||
|
result[key] = item
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param options [Hash{String, Symbol => Object}]
|
||||||
|
# @return [Hash{String => Object}]
|
||||||
|
def self.chrome_line!(options)
|
||||||
|
chrome_hash!(options).to_h do |name, value|
|
||||||
|
if %w[left center right].include?(name)
|
||||||
|
raise TypeError, "#{name} must be an Array" unless value.is_a?(Array)
|
||||||
|
[name, value.map { |item| chrome_name!(item) }]
|
||||||
|
else
|
||||||
|
chrome_name!(name)
|
||||||
|
style = chrome_hash!(value).to_h do |option, item|
|
||||||
|
case option
|
||||||
|
when "padding"
|
||||||
|
unless item.is_a?(Integer) && item.between?(0, 64)
|
||||||
|
raise ArgumentError, "padding must be an Integer between 0 and 64"
|
||||||
|
end
|
||||||
|
when "title", "leading_edge", "trailing_edge", "first_leading_edge"
|
||||||
|
item = string!(item, option)
|
||||||
|
else
|
||||||
|
raise ArgumentError, "unknown chrome style option: #{option}"
|
||||||
|
end
|
||||||
|
[option, item]
|
||||||
|
end
|
||||||
|
[name, style]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Collects options for the independently configured top and bottom bars.
|
||||||
|
class BarConfig
|
||||||
|
# @return [Hash{String => Object}]
|
||||||
|
attr_reader :top, :bottom
|
||||||
|
|
||||||
|
# @return [void]
|
||||||
|
def initialize
|
||||||
|
@top = {}
|
||||||
|
@bottom = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param options [Hash{String, Symbol => Object}]
|
||||||
|
# @return [Hash{String => Object}]
|
||||||
|
def top=(options)
|
||||||
|
@top = Vamp.chrome_line!(options)
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param options [Hash{String, Symbol => Object}]
|
||||||
|
# @return [Hash{String => Object}]
|
||||||
|
def bottom=(options)
|
||||||
|
@bottom = Vamp.chrome_line!(options)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Collects anchor options for the independently positioned command and search popups.
|
||||||
|
class PopupConfig
|
||||||
|
# @return [Hash{String => String}]
|
||||||
|
attr_reader :command, :search
|
||||||
|
|
||||||
|
# @return [void]
|
||||||
|
def initialize
|
||||||
|
@command = {}
|
||||||
|
@search = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param options [Hash{String, Symbol => String, Symbol}] anchor in the workspace's 3x3 grid
|
||||||
|
# @return [Hash{String => String}]
|
||||||
|
def command=(options)
|
||||||
|
@command = options!(options)
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param options [Hash{String, Symbol => String, Symbol}] anchor in the workspace's 3x3 grid
|
||||||
|
# @return [Hash{String => String}]
|
||||||
|
def search=(options)
|
||||||
|
@search = options!(options)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# @param options [Hash{String, Symbol => String, Symbol}]
|
||||||
|
# @return [Hash{String => String}]
|
||||||
|
def options!(options)
|
||||||
|
Vamp.chrome_hash!(options).to_h do |option, value|
|
||||||
|
raise ArgumentError, "unknown popup option: #{option}" unless option == "anchor"
|
||||||
|
value = value.to_s if value.is_a?(Symbol)
|
||||||
|
value = Vamp.string!(value, "popup anchor")
|
||||||
|
unless %w[top_left top_center top_right center_left center center_right bottom_left bottom_center bottom_right].include?(value)
|
||||||
|
raise ArgumentError, "unknown popup anchor: #{value}"
|
||||||
|
end
|
||||||
|
[option, value]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Collects and validates the Ruby startup configuration DSL.
|
# Collects and validates the Ruby startup configuration DSL.
|
||||||
class Config
|
class Config
|
||||||
# @return [Hash{String => Boolean, Hash{String, Symbol => Object}}] language presets/options
|
# @return [Hash{String => Boolean, Hash{String, Symbol => Object}}] language presets/options
|
||||||
attr_reader :lsp
|
attr_reader :lsp
|
||||||
|
|
||||||
|
# @return [Vamp::BarConfig]
|
||||||
|
attr_reader :bar
|
||||||
|
|
||||||
|
# @return [Vamp::PopupConfig]
|
||||||
|
attr_reader :popup
|
||||||
|
|
||||||
# @return [void]
|
# @return [void]
|
||||||
def initialize
|
def initialize
|
||||||
@plugins = []
|
@plugins = []
|
||||||
@@ -16,6 +136,9 @@ module Vamp
|
|||||||
@file_picker_view = "tree"
|
@file_picker_view = "tree"
|
||||||
@file_explorer_view = "tree"
|
@file_explorer_view = "tree"
|
||||||
@clipboard_mode = "system"
|
@clipboard_mode = "system"
|
||||||
|
@bar = BarConfig.new
|
||||||
|
@popup = PopupConfig.new
|
||||||
|
@labels = {}
|
||||||
@diff_context_lines = 10
|
@diff_context_lines = 10
|
||||||
@diff_expand_lines = 15
|
@diff_expand_lines = 15
|
||||||
end
|
end
|
||||||
@@ -62,6 +185,14 @@ module Vamp
|
|||||||
@clipboard_mode = nonempty_string!(mode, "clipboard mode")
|
@clipboard_mode = nonempty_string!(mode, "clipboard mode")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @param values [Hash{String, Symbol => String}]
|
||||||
|
# @return [Hash]
|
||||||
|
def labels=(values)
|
||||||
|
@labels = Vamp.chrome_hash!(values).to_h do |name, text|
|
||||||
|
[Vamp.chrome_name!(name), Vamp.string!(text, "label text")]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# @param lines [Integer] nonnegative context lines around each diff change
|
# @param lines [Integer] nonnegative context lines around each diff change
|
||||||
# @return [Integer]
|
# @return [Integer]
|
||||||
def diff_context_lines=(lines)
|
def diff_context_lines=(lines)
|
||||||
@@ -105,11 +236,12 @@ module Vamp
|
|||||||
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings,
|
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings,
|
||||||
"leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view,
|
"leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view,
|
||||||
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
|
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
|
||||||
|
"top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels,
|
||||||
|
"popup" => { "command" => @popup.command, "search" => @popup.search },
|
||||||
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines }
|
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines }
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# @param value [Integer]
|
# @param value [Integer]
|
||||||
# @param label [String]
|
# @param label [String]
|
||||||
# @param minimum [Integer]
|
# @param minimum [Integer]
|
||||||
@@ -203,7 +335,7 @@ module Vamp
|
|||||||
attr_reader :path
|
attr_reader :path
|
||||||
# @return [Integer] positive invocation ID
|
# @return [Integer] positive invocation ID
|
||||||
attr_reader :request_id
|
attr_reader :request_id
|
||||||
# @return [Array<Hash{String => String}>] queued insert/replace/message effects
|
# @return [Array<Hash{String => String}>] queued text and presentation effects
|
||||||
attr_reader :effects
|
attr_reader :effects
|
||||||
|
|
||||||
# @param context [Hash{String => String, Integer, nil}] primary caret/document snapshot
|
# @param context [Hash{String => String, Integer, nil}] primary caret/document snapshot
|
||||||
@@ -237,6 +369,14 @@ module Vamp
|
|||||||
queue("message", text)
|
queue("message", text)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @param name [String, Symbol]
|
||||||
|
# @param text [String] named session-wide plain text; empty clears it
|
||||||
|
# @return [nil]
|
||||||
|
def label(name, text)
|
||||||
|
name = Vamp.chrome_name!(name)
|
||||||
|
queue("label", text, name)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# @param context [Hash{String => String, Integer, nil}]
|
# @param context [Hash{String => String, Integer, nil}]
|
||||||
@@ -281,15 +421,18 @@ module Vamp
|
|||||||
raise ArgumentError, "context buffer_id and revision must be nonnegative integers"
|
raise ArgumentError, "context buffer_id and revision must be nonnegative integers"
|
||||||
end
|
end
|
||||||
|
|
||||||
# @param type [String] insert, replace, or message
|
# @param type [String] insert, replace, message, or label
|
||||||
# @param text [String]
|
# @param text [String]
|
||||||
|
# @param name [String, nil]
|
||||||
# @return [nil]
|
# @return [nil]
|
||||||
def queue(type, text)
|
def queue(type, text, name = nil)
|
||||||
text = Vamp.string!(text, "effect text")
|
text = Vamp.string!(text, "effect text")
|
||||||
@effect_bytes += text.bytesize
|
@effect_bytes += text.bytesize + (name ? name.bytesize : 0)
|
||||||
raise ArgumentError, "effects exceed 4 MiB" if @effect_bytes > MAX_FRAME
|
raise ArgumentError, "effects exceed 4 MiB" if @effect_bytes > MAX_FRAME
|
||||||
|
|
||||||
@effects << { "type" => type, "text" => text }
|
effect = { "type" => type, "text" => text }
|
||||||
|
effect["name"] = name if name
|
||||||
|
@effects << effect
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+466
-50
@@ -166,10 +166,13 @@ pub struct EditorSession {
|
|||||||
lsp_request_id: u64,
|
lsp_request_id: u64,
|
||||||
lsp_source_cursor: Option<Cursor>,
|
lsp_source_cursor: Option<Cursor>,
|
||||||
lsp_source_mode: Mode,
|
lsp_source_mode: Mode,
|
||||||
|
lsp_source_view: Option<(usize, Option<u64>)>,
|
||||||
pending_hover: bool,
|
pending_hover: bool,
|
||||||
pending_completion: bool,
|
pending_completion: bool,
|
||||||
hover_owner: Option<String>,
|
hover_owner: Option<String>,
|
||||||
diff_worker: Option<DiffWorker>,
|
diff_workers: BTreeMap<usize, DiffWorker>,
|
||||||
|
battery: Option<crate::battery::Battery>,
|
||||||
|
battery_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EditorSession {
|
impl EditorSession {
|
||||||
@@ -220,6 +223,12 @@ impl EditorSession {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
|
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
|
||||||
|
if let Err(error) =
|
||||||
|
editor.set_chrome_config(config.top_bar, config.bottom_bar, config.labels)
|
||||||
|
{
|
||||||
|
notices.push(format!("chrome: {error}"));
|
||||||
|
}
|
||||||
|
editor.set_popup_config(config.popup);
|
||||||
let mut plugins = BTreeMap::new();
|
let mut plugins = BTreeMap::new();
|
||||||
for configured in config.plugins {
|
for configured in config.plugins {
|
||||||
let path = configured.path;
|
let path = configured.path;
|
||||||
@@ -244,9 +253,7 @@ impl EditorSession {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if notices.is_empty() {
|
if !notices.is_empty() {
|
||||||
editor.set_message("Ready • --no-config uses default keybindings");
|
|
||||||
} else {
|
|
||||||
editor.set_message(notices.join(" | "));
|
editor.set_message(notices.join(" | "));
|
||||||
}
|
}
|
||||||
let mut session = Self {
|
let mut session = Self {
|
||||||
@@ -258,12 +265,16 @@ impl EditorSession {
|
|||||||
lsp_request_id: 0,
|
lsp_request_id: 0,
|
||||||
lsp_source_cursor: None,
|
lsp_source_cursor: None,
|
||||||
lsp_source_mode: Mode::Normal,
|
lsp_source_mode: Mode::Normal,
|
||||||
|
lsp_source_view: None,
|
||||||
pending_hover: false,
|
pending_hover: false,
|
||||||
pending_completion: false,
|
pending_completion: false,
|
||||||
hover_owner: None,
|
hover_owner: None,
|
||||||
diff_worker: None,
|
diff_workers: BTreeMap::new(),
|
||||||
|
battery: None,
|
||||||
|
battery_enabled: false,
|
||||||
};
|
};
|
||||||
session.synchronize_documents();
|
session.synchronize_documents();
|
||||||
|
session.poll_battery();
|
||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,10 +301,12 @@ impl EditorSession {
|
|||||||
|
|
||||||
pub(crate) fn scroll_diff(&mut self, focus: Focus, down: bool) {
|
pub(crate) fn scroll_diff(&mut self, focus: Focus, down: bool) {
|
||||||
self.editor.scroll_diff(focus, down);
|
self.editor.scroll_diff(focus, down);
|
||||||
|
self.synchronize_documents();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn scroll_diff_horizontal(&mut self, focus: Focus, right: bool, width: usize) {
|
pub(crate) fn scroll_diff_horizontal(&mut self, focus: Focus, right: bool, width: usize) {
|
||||||
self.editor.scroll_diff_horizontal(focus, right, width);
|
self.editor.scroll_diff_horizontal(focus, right, width);
|
||||||
|
self.synchronize_documents();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle(&mut self, input: Input) {
|
pub fn handle(&mut self, input: Input) {
|
||||||
@@ -319,27 +332,27 @@ impl EditorSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn start_diff_request(&mut self) {
|
fn start_diff_request(&mut self) {
|
||||||
if self.editor.workspace_diff().is_none() {
|
self.diff_workers.retain(|tab, worker| {
|
||||||
self.diff_worker = None;
|
let Some(diff) = self.editor.tab_diff(*tab) else {
|
||||||
return;
|
return false;
|
||||||
}
|
};
|
||||||
if self
|
if !diff.loading
|
||||||
.editor
|
|
||||||
.workspace_diff()
|
|
||||||
.is_some_and(|diff| !diff.loading)
|
|
||||||
&& let Some(worker) = &mut self.diff_worker
|
|
||||||
&& let Some(id) = worker.pending.take()
|
&& let Some(id) = worker.pending.take()
|
||||||
{
|
{
|
||||||
worker.cancelled_request.store(id, Ordering::Release);
|
worker.cancelled_request.store(id, Ordering::Release);
|
||||||
worker.cancelled.store(true, Ordering::Release);
|
worker.cancelled.store(true, Ordering::Release);
|
||||||
}
|
}
|
||||||
|
true
|
||||||
|
});
|
||||||
let Some((id, operation)) = self.editor.take_diff_request() else {
|
let Some((id, operation)) = self.editor.take_diff_request() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if self.diff_worker.is_none() {
|
let tab = self.editor.tab_id();
|
||||||
let root = self.editor.diff_root();
|
if let std::collections::btree_map::Entry::Vacant(entry) = self.diff_workers.entry(tab) {
|
||||||
match DiffWorker::start(root) {
|
match DiffWorker::start(self.editor.diff_root()) {
|
||||||
Ok(worker) => self.diff_worker = Some(worker),
|
Ok(worker) => {
|
||||||
|
entry.insert(worker);
|
||||||
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.editor.apply_diff_result(id, Err(error));
|
self.editor.apply_diff_result(id, Err(error));
|
||||||
return;
|
return;
|
||||||
@@ -347,20 +360,21 @@ impl EditorSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Err(error) = self
|
if let Err(error) = self
|
||||||
.diff_worker
|
.diff_workers
|
||||||
.as_mut()
|
.get_mut(&tab)
|
||||||
.expect("diff worker started")
|
.expect("diff worker started")
|
||||||
.request(id, operation)
|
.request(id, operation)
|
||||||
{
|
{
|
||||||
self.editor.apply_diff_result(id, Err(error));
|
self.editor.apply_diff_result(id, Err(error));
|
||||||
self.diff_worker = None;
|
self.diff_workers.remove(&tab);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn poll_diff(&mut self) -> bool {
|
fn poll_diff(&mut self) -> bool {
|
||||||
self.start_diff_request();
|
self.start_diff_request();
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
while let Some(worker) = self.diff_worker.as_ref() {
|
self.diff_workers.retain(|_, worker| {
|
||||||
|
loop {
|
||||||
let result = worker
|
let result = worker
|
||||||
.results
|
.results
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -368,35 +382,39 @@ impl EditorSession {
|
|||||||
.try_recv();
|
.try_recv();
|
||||||
match result {
|
match result {
|
||||||
Ok((id, result)) => {
|
Ok((id, result)) => {
|
||||||
if let Some(worker) = &mut self.diff_worker
|
if worker.pending == Some(id) {
|
||||||
&& worker.pending == Some(id)
|
|
||||||
{
|
|
||||||
worker.pending = None;
|
worker.pending = None;
|
||||||
}
|
}
|
||||||
changed |= self.editor.apply_diff_result(id, result);
|
changed |= self.editor.apply_diff_result(id, result);
|
||||||
}
|
}
|
||||||
Err(mpsc::TryRecvError::Empty) => break,
|
Err(mpsc::TryRecvError::Empty) => return true,
|
||||||
Err(mpsc::TryRecvError::Disconnected) => {
|
Err(mpsc::TryRecvError::Disconnected) => {
|
||||||
if let Some(id) = self.diff_worker.as_ref().and_then(|worker| worker.pending) {
|
if let Some(id) = worker.pending.take() {
|
||||||
changed |= self
|
changed |= self.editor.apply_diff_result(
|
||||||
.editor
|
id,
|
||||||
.apply_diff_result(id, Err("Diff worker stopped unexpectedly".into()));
|
Err("Diff worker stopped unexpectedly".into()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
self.diff_worker = None;
|
return false;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
changed
|
changed
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch(&mut self, invocation: Invocation) {
|
fn dispatch(&mut self, invocation: Invocation) {
|
||||||
if invocation.plugin == "core" {
|
if invocation.plugin == "core" {
|
||||||
|
if !self.editor.document_actions_available()
|
||||||
|
|| (invocation.command == "lsp.completion" && self.editor.mutation_blocked())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
let result = match invocation.command.as_str() {
|
let result = match invocation.command.as_str() {
|
||||||
"lsp.definition" | "lsp.hover" | "lsp.completion" => {
|
"lsp.definition" | "lsp.hover" | "lsp.completion" => {
|
||||||
if let Some(id) = self.lsp_request_id.checked_add(1) {
|
if let Some(id) = self.lsp_request_id.checked_add(1) {
|
||||||
self.lsp_request_id = id;
|
self.lsp_request_id = id;
|
||||||
let context = self.editor.context();
|
let context = self.editor.lsp_context();
|
||||||
let source = InvocationSource {
|
let source = InvocationSource {
|
||||||
buffer_id: context.buffer_id,
|
buffer_id: context.buffer_id,
|
||||||
pane_id: self.editor.pane_id(),
|
pane_id: self.editor.pane_id(),
|
||||||
@@ -405,6 +423,10 @@ impl EditorSession {
|
|||||||
self.requests.entry("core".into()).or_default().latest = Some((id, source));
|
self.requests.entry("core".into()).or_default().latest = Some((id, source));
|
||||||
self.lsp_source_cursor = Some(self.editor.cursor());
|
self.lsp_source_cursor = Some(self.editor.cursor());
|
||||||
self.lsp_source_mode = self.editor.mode();
|
self.lsp_source_mode = self.editor.mode();
|
||||||
|
self.lsp_source_view = Some((
|
||||||
|
self.editor.tab_id(),
|
||||||
|
self.editor.diff().and_then(|diff| diff.comparison_id),
|
||||||
|
));
|
||||||
self.pending_hover = invocation.command == "lsp.hover";
|
self.pending_hover = invocation.command == "lsp.hover";
|
||||||
self.pending_completion = invocation.command == "lsp.completion";
|
self.pending_completion = invocation.command == "lsp.completion";
|
||||||
match invocation.command.as_str() {
|
match invocation.command.as_str() {
|
||||||
@@ -493,24 +515,37 @@ impl EditorSession {
|
|||||||
|| source.revision != self.editor.text_revision()
|
|| source.revision != self.editor.text_revision()
|
||||||
|| self.lsp_source_cursor != Some(self.editor.cursor())
|
|| self.lsp_source_cursor != Some(self.editor.cursor())
|
||||||
|| self.editor.mode() != self.lsp_source_mode
|
|| self.editor.mode() != self.lsp_source_mode
|
||||||
|
|| self.lsp_source_view
|
||||||
|
!= Some((
|
||||||
|
self.editor.tab_id(),
|
||||||
|
self.editor.diff().and_then(|diff| diff.comparison_id),
|
||||||
|
))
|
||||||
|
|| !self.editor.document_actions_available()
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
requests.latest = None;
|
requests.latest = None;
|
||||||
self.lsp_source_cursor = None;
|
self.lsp_source_cursor = None;
|
||||||
|
self.lsp_source_view = None;
|
||||||
self.pending_hover = false;
|
self.pending_hover = false;
|
||||||
self.pending_completion = false;
|
self.pending_completion = false;
|
||||||
}
|
}
|
||||||
if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) {
|
if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) {
|
||||||
self.documents.clear();
|
self.documents.clear();
|
||||||
return false;
|
return self.refresh_lsp_names();
|
||||||
}
|
}
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
if !self.editor.should_quit() {
|
if !self.editor.should_quit() {
|
||||||
for document in self.editor.interaction_documents() {
|
for document in self.editor.interaction_documents() {
|
||||||
let path = document.path.map(Path::to_string_lossy);
|
let path = document.path.map(Path::to_string_lossy);
|
||||||
|
let root = self
|
||||||
|
.editor
|
||||||
|
.interaction_document_root(document.id)
|
||||||
|
.to_string_lossy();
|
||||||
let previous = self.documents.get(&document.id);
|
let previous = self.documents.get(&document.id);
|
||||||
let renamed = previous
|
let renamed = previous.is_some_and(|previous| {
|
||||||
.is_some_and(|previous| previous.snapshot.path.as_deref() != path.as_deref());
|
previous.snapshot.path.as_deref() != path.as_deref()
|
||||||
|
|| previous.snapshot.root.as_str() != root.as_ref()
|
||||||
|
});
|
||||||
if renamed {
|
if renamed {
|
||||||
events.push(DocumentEvent {
|
events.push(DocumentEvent {
|
||||||
event: DocumentEventKind::Close,
|
event: DocumentEventKind::Close,
|
||||||
@@ -525,7 +560,7 @@ impl EditorSession {
|
|||||||
id: document.id,
|
id: document.id,
|
||||||
revision: document.revision,
|
revision: document.revision,
|
||||||
path: path.map(|path| path.into_owned()),
|
path: path.map(|path| path.into_owned()),
|
||||||
root: self.editor.project_root().to_string_lossy().into_owned(),
|
root: root.into_owned(),
|
||||||
text: document.lines.join("\n"),
|
text: document.lines.join("\n"),
|
||||||
};
|
};
|
||||||
events.push(DocumentEvent {
|
events.push(DocumentEvent {
|
||||||
@@ -594,7 +629,25 @@ impl EditorSession {
|
|||||||
for (owner, error) in failures {
|
for (owner, error) in failures {
|
||||||
self.retire(&owner, &error);
|
self.retire(&owner, &error);
|
||||||
}
|
}
|
||||||
changed
|
changed | self.refresh_lsp_names()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_lsp_names(&mut self) -> bool {
|
||||||
|
let buffer_id = self.editor.buffer_id();
|
||||||
|
if self.lsp.active_server_names(buffer_id).eq(self
|
||||||
|
.editor
|
||||||
|
.lsp_names()
|
||||||
|
.iter()
|
||||||
|
.map(String::as_str))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.editor.set_lsp_names(
|
||||||
|
self.lsp
|
||||||
|
.active_server_names(buffer_id)
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn source_is_current(&self, source: InvocationSource) -> bool {
|
fn source_is_current(&self, source: InvocationSource) -> bool {
|
||||||
@@ -609,14 +662,19 @@ impl EditorSession {
|
|||||||
request_id: Option<u64>,
|
request_id: Option<u64>,
|
||||||
source: InvocationSource,
|
source: InvocationSource,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if self.editor.diff().is_some() && !self.editor.diff_interacting() {
|
if !self.editor.document_actions_available() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.editor.buffer_id() == source.buffer_id
|
self.editor.buffer_id() == source.buffer_id
|
||||||
&& self.source_is_current(source)
|
&& self.source_is_current(source)
|
||||||
&& (owner != "core"
|
&& (owner != "core"
|
||||||
|| (self.lsp_source_cursor == Some(self.editor.cursor())
|
|| (self.lsp_source_cursor == Some(self.editor.cursor())
|
||||||
&& self.editor.mode() == self.lsp_source_mode))
|
&& self.editor.mode() == self.lsp_source_mode
|
||||||
|
&& self.lsp_source_view
|
||||||
|
== Some((
|
||||||
|
self.editor.tab_id(),
|
||||||
|
self.editor.diff().and_then(|diff| diff.comparison_id),
|
||||||
|
))))
|
||||||
&& self
|
&& self
|
||||||
.requests
|
.requests
|
||||||
.get(owner)
|
.get(owner)
|
||||||
@@ -657,7 +715,7 @@ impl EditorSession {
|
|||||||
&& self.editor.startup_diagnostic().is_none()
|
&& self.editor.startup_diagnostic().is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_update(&mut self, owner: &str, update: PluginUpdate) -> Result<bool, String> {
|
fn apply_update(&mut self, owner: &str, mut update: PluginUpdate) -> Result<bool, String> {
|
||||||
if owner == "core"
|
if owner == "core"
|
||||||
&& let Some(id) = update.request_id
|
&& let Some(id) = update.request_id
|
||||||
&& self
|
&& self
|
||||||
@@ -667,7 +725,9 @@ impl EditorSession {
|
|||||||
.map(|latest| latest.0)
|
.map(|latest| latest.0)
|
||||||
!= Some(id)
|
!= Some(id)
|
||||||
{
|
{
|
||||||
return Ok(false);
|
update
|
||||||
|
.effects
|
||||||
|
.retain(|effect| matches!(effect, UpdateEffect::Label { .. }));
|
||||||
}
|
}
|
||||||
for effect in &update.effects {
|
for effect in &update.effects {
|
||||||
if let UpdateEffect::Completion {
|
if let UpdateEffect::Completion {
|
||||||
@@ -696,6 +756,7 @@ impl EditorSession {
|
|||||||
}
|
}
|
||||||
// Validate the whole applicable annotation batch before changing any buffer.
|
// Validate the whole applicable annotation batch before changing any buffer.
|
||||||
let validation = (|| -> Result<(), String> {
|
let validation = (|| -> Result<(), String> {
|
||||||
|
let mut label_bytes = 0usize;
|
||||||
for effect in &update.effects {
|
for effect in &update.effects {
|
||||||
match effect {
|
match effect {
|
||||||
UpdateEffect::Diagnostics {
|
UpdateEffect::Diagnostics {
|
||||||
@@ -725,7 +786,16 @@ impl EditorSession {
|
|||||||
revision,
|
revision,
|
||||||
path,
|
path,
|
||||||
..
|
..
|
||||||
|
}
|
||||||
|
| UpdateEffect::NavigateUtf16 {
|
||||||
|
buffer_id,
|
||||||
|
revision,
|
||||||
|
path,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
|
if owner != "core" && matches!(effect, UpdateEffect::NavigateUtf16 { .. }) {
|
||||||
|
return Err("UTF-16 navigation is a native LSP update only".into());
|
||||||
|
}
|
||||||
let source = InvocationSource {
|
let source = InvocationSource {
|
||||||
buffer_id: *buffer_id,
|
buffer_id: *buffer_id,
|
||||||
pane_id: self.editor.pane_id(),
|
pane_id: self.editor.pane_id(),
|
||||||
@@ -756,6 +826,16 @@ impl EditorSession {
|
|||||||
}
|
}
|
||||||
UpdateEffect::Completion { .. } => {}
|
UpdateEffect::Completion { .. } => {}
|
||||||
UpdateEffect::Message { .. } => {}
|
UpdateEffect::Message { .. } => {}
|
||||||
|
UpdateEffect::Label { name, text } => {
|
||||||
|
crate::editor::validate_label(name, text)?;
|
||||||
|
label_bytes = label_bytes
|
||||||
|
.checked_add(name.len())
|
||||||
|
.and_then(|bytes| bytes.checked_add(text.len()))
|
||||||
|
.ok_or("Labels exceed 4 MiB")?;
|
||||||
|
if label_bytes > 4 * 1024 * 1024 {
|
||||||
|
return Err("Labels exceed 4 MiB".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -787,6 +867,26 @@ impl EditorSession {
|
|||||||
.editor
|
.editor
|
||||||
.replace_validated_semantic_tokens(buffer_id, revision, tokens);
|
.replace_validated_semantic_tokens(buffer_id, revision, tokens);
|
||||||
}
|
}
|
||||||
|
UpdateEffect::NavigateUtf16 {
|
||||||
|
buffer_id,
|
||||||
|
revision,
|
||||||
|
path,
|
||||||
|
range,
|
||||||
|
target_range,
|
||||||
|
} => {
|
||||||
|
let source = InvocationSource {
|
||||||
|
buffer_id,
|
||||||
|
pane_id: self.editor.pane_id(),
|
||||||
|
revision,
|
||||||
|
};
|
||||||
|
if self.request_is_current(owner, update.request_id, source) {
|
||||||
|
if let Err(error) = self.editor.navigate_lsp_to(path, range, target_range) {
|
||||||
|
self.editor
|
||||||
|
.set_message(format!("LSP: definition failed: {error}"));
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
UpdateEffect::Navigate {
|
UpdateEffect::Navigate {
|
||||||
buffer_id,
|
buffer_id,
|
||||||
revision,
|
revision,
|
||||||
@@ -851,6 +951,9 @@ impl EditorSession {
|
|||||||
changed |= self.editor.message() != text;
|
changed |= self.editor.message() != text;
|
||||||
self.editor.set_message(text);
|
self.editor.set_message(text);
|
||||||
}
|
}
|
||||||
|
UpdateEffect::Label { name, text } => {
|
||||||
|
changed |= self.editor.set_label(name, text)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.editor.refresh_live_diff();
|
self.editor.refresh_live_diff();
|
||||||
@@ -861,7 +964,7 @@ impl EditorSession {
|
|||||||
&mut self,
|
&mut self,
|
||||||
owner: &str,
|
owner: &str,
|
||||||
id: u64,
|
id: u64,
|
||||||
effects: Vec<Effect>,
|
mut effects: Vec<Effect>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let source = self
|
let source = self
|
||||||
.requests
|
.requests
|
||||||
@@ -870,22 +973,30 @@ impl EditorSession {
|
|||||||
let Some(source) = source else {
|
let Some(source) = source else {
|
||||||
return Err("plugin response has an unknown invocation ID".into());
|
return Err("plugin response has an unknown invocation ID".into());
|
||||||
};
|
};
|
||||||
if !self.source_is_current(source)
|
// Document effects retain source freshness; labels belong to the whole session.
|
||||||
|| self.editor.buffer_id() != source.buffer_id
|
let current = self.source_is_current(source)
|
||||||
|| self.editor.pane_id() != source.pane_id
|
&& self.editor.buffer_id() == source.buffer_id
|
||||||
{
|
&& self.editor.pane_id() == source.pane_id;
|
||||||
return Ok(false);
|
if !current {
|
||||||
|
effects.retain(|effect| matches!(effect, Effect::Label { .. }));
|
||||||
}
|
}
|
||||||
if effects
|
if effects
|
||||||
.iter()
|
.iter()
|
||||||
.all(|effect| matches!(effect, Effect::Message(_)))
|
.all(|effect| matches!(effect, Effect::Message(_) | Effect::Label { .. }))
|
||||||
{
|
{
|
||||||
|
crate::editor::validate_effects(&effects)?;
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
for effect in effects {
|
for effect in effects {
|
||||||
if let Effect::Message(text) = effect {
|
match effect {
|
||||||
|
Effect::Message(text) => {
|
||||||
changed |= self.editor.message() != text;
|
changed |= self.editor.message() != text;
|
||||||
self.editor.set_message(text);
|
self.editor.set_message(text);
|
||||||
}
|
}
|
||||||
|
Effect::Label { name, text } => {
|
||||||
|
changed |= self.editor.set_label(name, text)?;
|
||||||
|
}
|
||||||
|
_ => unreachable!("presentation-only effects checked above"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Ok(changed);
|
return Ok(changed);
|
||||||
}
|
}
|
||||||
@@ -897,9 +1008,46 @@ impl EditorSession {
|
|||||||
Ok(changed)
|
Ok(changed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn poll_battery(&mut self) -> bool {
|
||||||
|
let enabled = self.editor.label("battery").is_empty()
|
||||||
|
&& [
|
||||||
|
self.editor.top_bar_config(),
|
||||||
|
self.editor.bottom_bar_config(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.any(|bar| {
|
||||||
|
bar.left
|
||||||
|
.iter()
|
||||||
|
.chain(&bar.center)
|
||||||
|
.chain(&bar.right)
|
||||||
|
.any(|name| name == "battery")
|
||||||
|
});
|
||||||
|
let mut changed = false;
|
||||||
|
if enabled != self.battery_enabled {
|
||||||
|
self.battery_enabled = enabled;
|
||||||
|
// Unit sessions do not read external hardware; provider and renderer checks inject data.
|
||||||
|
#[cfg(not(test))]
|
||||||
|
{
|
||||||
|
self.battery = if enabled {
|
||||||
|
crate::battery::Battery::start().ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if !enabled {
|
||||||
|
changed |= self.editor.set_battery_charge(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(charge) = self.battery.as_mut().and_then(|battery| battery.poll()) {
|
||||||
|
changed |= self.editor.set_battery_charge(charge);
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
/// Drains ready plugin work without waiting for command or server responses.
|
/// Drains ready plugin work without waiting for command or server responses.
|
||||||
pub fn poll(&mut self) -> bool {
|
pub fn poll(&mut self) -> bool {
|
||||||
let mut changed = self.synchronize_documents();
|
let mut changed = self.synchronize_documents();
|
||||||
|
changed |= self.poll_battery();
|
||||||
changed |= self.poll_diff();
|
changed |= self.poll_diff();
|
||||||
for update in self.lsp.poll() {
|
for update in self.lsp.poll() {
|
||||||
match self.apply_update("core", update) {
|
match self.apply_update("core", update) {
|
||||||
@@ -962,6 +1110,68 @@ mod tests {
|
|||||||
sync::atomic::{AtomicU64, Ordering},
|
sync::atomic::{AtomicU64, Ordering},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_navigation_resolves_utf16_and_validates_full_ranges_atomically() {
|
||||||
|
use crate::plugin::Utf16Range;
|
||||||
|
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let source = fixture.file("buffer.txt", "source");
|
||||||
|
let target = fixture.file("target.txt", "A😀e\u{301}Z\n");
|
||||||
|
let mut session = EditorSession::open(Some(source.clone()), vec![], None).unwrap();
|
||||||
|
session.dispatch(Invocation {
|
||||||
|
plugin: "core".into(),
|
||||||
|
command: "lsp.definition".into(),
|
||||||
|
args: String::new(),
|
||||||
|
});
|
||||||
|
let buffer_id = session.editor.buffer_id();
|
||||||
|
let revision = session.editor.text_revision();
|
||||||
|
let request_id = session.lsp_request_id;
|
||||||
|
let span = Utf16Range {
|
||||||
|
start: (0, 5),
|
||||||
|
end: (0, 6),
|
||||||
|
};
|
||||||
|
let invalid = Utf16Range {
|
||||||
|
end: (9, 0),
|
||||||
|
..span
|
||||||
|
};
|
||||||
|
let update = |range, target_range| PluginUpdate {
|
||||||
|
request_id: Some(request_id),
|
||||||
|
effects: vec![UpdateEffect::NavigateUtf16 {
|
||||||
|
buffer_id,
|
||||||
|
revision,
|
||||||
|
path: target.clone(),
|
||||||
|
range,
|
||||||
|
target_range,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
for (range, target_range) in [(invalid, None), (span, Some(invalid))] {
|
||||||
|
assert!(
|
||||||
|
session
|
||||||
|
.apply_update("core", update(range, target_range))
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(session.editor.buffer_id(), buffer_id);
|
||||||
|
assert_eq!(session.editor.path(), Some(source.as_path()));
|
||||||
|
assert_eq!(session.editor.buffer_count(), 1);
|
||||||
|
assert_eq!(session.editor.tab_count(), 1);
|
||||||
|
assert_eq!(session.editor.cursor(), Cursor::default());
|
||||||
|
assert!(session.editor.message().contains("definition failed"));
|
||||||
|
}
|
||||||
|
assert!(session.apply_update("fixture", update(span, None)).is_err());
|
||||||
|
assert_eq!(session.editor.buffer_id(), buffer_id);
|
||||||
|
assert!(
|
||||||
|
session
|
||||||
|
.apply_update("core", update(span, Some(span)))
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(session.editor.path(), Some(target.as_path()));
|
||||||
|
assert_eq!(session.editor.cursor(), Cursor { row: 0, column: 3 });
|
||||||
|
assert_eq!(session.editor.tab_count(), 1);
|
||||||
|
session.handle(Input::Ctrl('o'));
|
||||||
|
assert_eq!(session.editor.path(), Some(source.as_path()));
|
||||||
|
assert_eq!(session.editor.cursor(), Cursor::default());
|
||||||
|
}
|
||||||
|
|
||||||
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
|
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -999,6 +1209,10 @@ mod tests {
|
|||||||
));
|
));
|
||||||
session.lsp_source_cursor = Some(session.editor.cursor());
|
session.lsp_source_cursor = Some(session.editor.cursor());
|
||||||
session.lsp_source_mode = session.editor.mode();
|
session.lsp_source_mode = session.editor.mode();
|
||||||
|
session.lsp_source_view = Some((
|
||||||
|
session.editor.tab_id(),
|
||||||
|
session.editor.diff().and_then(|diff| diff.comparison_id),
|
||||||
|
));
|
||||||
session.editor_mut().with_render_pane(1, |pane| {
|
session.editor_mut().with_render_pane(1, |pane| {
|
||||||
assert_eq!(pane.pane_id(), 1);
|
assert_eq!(pane.pane_id(), 1);
|
||||||
assert_eq!(pane.lines(), ["source"]);
|
assert_eq!(pane.lines(), ["source"]);
|
||||||
@@ -2980,6 +3194,208 @@ end
|
|||||||
drain_until(session, |session| session.editor().message() == delivered);
|
drain_until(session, |session| session.editor().message() == delivered);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_chrome_installs_named_values_without_editing_the_document() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
fixture.file("buffer.txt", "source");
|
||||||
|
let session = fixture.open(
|
||||||
|
r#"
|
||||||
|
Vamp.configure do |config|
|
||||||
|
config.bar.top = {left: [:mode, :language], right: [:battery]}
|
||||||
|
config.bar.bottom = {left: [:tabs], center: [:workspace]}
|
||||||
|
config.labels = {battery: "bat 38%", workspace: "tools\n"}
|
||||||
|
end
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
assert_eq!(session.editor.label("battery"), "bat 38%");
|
||||||
|
assert_eq!(session.editor.label("workspace"), "tools�");
|
||||||
|
assert_eq!(session.editor.top_bar_config().left, ["mode", "language"]);
|
||||||
|
assert_eq!(session.editor.top_bar_config().right, ["battery"]);
|
||||||
|
assert_eq!(session.editor.bottom_bar_config().left, ["tabs"]);
|
||||||
|
assert_eq!(session.editor.bottom_bar_config().center, ["workspace"]);
|
||||||
|
assert_eq!(session.editor.lines(), ["source"]);
|
||||||
|
assert_eq!(session.editor.text_revision(), 0);
|
||||||
|
assert!(!session.editor.dirty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn asynchronous_result_labels_outlive_the_source_without_applying_stale_edits() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let target = fixture.file("other.txt", "");
|
||||||
|
let mut session = EditorSession::open(None, vec![], None).unwrap();
|
||||||
|
let source = InvocationSource {
|
||||||
|
buffer_id: session.editor.buffer_id(),
|
||||||
|
pane_id: session.editor.pane_id(),
|
||||||
|
revision: session.editor.text_revision(),
|
||||||
|
};
|
||||||
|
session.editor.execute("tab.new");
|
||||||
|
session
|
||||||
|
.editor
|
||||||
|
.execute(&format!("edit {}", target.display()));
|
||||||
|
assert_ne!(session.editor.buffer_id(), source.buffer_id);
|
||||||
|
session.editor.handle(Input::Char('i'));
|
||||||
|
let message = session.editor.message().to_owned();
|
||||||
|
for (id, effects) in [
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
vec![
|
||||||
|
Effect::Insert("stale".into()),
|
||||||
|
Effect::Message("stale message".into()),
|
||||||
|
Effect::Label {
|
||||||
|
name: "battery".into(),
|
||||||
|
text: "bat 38%".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
2,
|
||||||
|
vec![
|
||||||
|
Effect::Label {
|
||||||
|
name: "battery".into(),
|
||||||
|
text: "must not leak".into(),
|
||||||
|
},
|
||||||
|
Effect::Label {
|
||||||
|
name: "bad-name".into(),
|
||||||
|
text: "invalid".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
session
|
||||||
|
.requests
|
||||||
|
.entry("fixture".into())
|
||||||
|
.or_default()
|
||||||
|
.pending
|
||||||
|
.insert(id, source);
|
||||||
|
let result = session.apply_response("fixture", id, effects);
|
||||||
|
if id == 1 {
|
||||||
|
assert!(result.unwrap());
|
||||||
|
} else {
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
assert_eq!(session.editor.label("battery"), "bat 38%");
|
||||||
|
assert_eq!(session.editor.lines(), [""]);
|
||||||
|
assert_eq!(session.editor.message(), message);
|
||||||
|
assert_eq!(session.editor.mode(), Mode::Insert);
|
||||||
|
assert_eq!(session.editor.text_revision(), 0);
|
||||||
|
assert!(!session.editor.dirty());
|
||||||
|
}
|
||||||
|
session.editor.handle(Input::Char('x'));
|
||||||
|
session.editor.handle(Input::Escape);
|
||||||
|
session.editor.handle(Input::Char('u'));
|
||||||
|
assert_eq!(session.editor.lines(), [""]);
|
||||||
|
assert_eq!(session.editor.label("battery"), "bat 38%");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn named_labels_survive_tab_switches_and_remain_atomic_in_readonly_views() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let mut session = fixture.asynchronous(None);
|
||||||
|
publish(
|
||||||
|
&mut session,
|
||||||
|
vec![serde_json::json!({
|
||||||
|
"type": "label", "name": "battery", "text": "bat 37%"
|
||||||
|
})],
|
||||||
|
);
|
||||||
|
drain_until(&mut session, |session| {
|
||||||
|
session.editor().label("battery") == "bat 37%"
|
||||||
|
});
|
||||||
|
let gate = fixture.0.join("label.gate");
|
||||||
|
hold(
|
||||||
|
&mut session,
|
||||||
|
&gate,
|
||||||
|
vec![serde_json::json!({
|
||||||
|
"type": "label", "name": "battery", "text": "bat 38%"
|
||||||
|
})],
|
||||||
|
);
|
||||||
|
barrier(&mut session, "label request received");
|
||||||
|
session.execute("tab.new");
|
||||||
|
release(&mut session, &gate);
|
||||||
|
assert_eq!(session.editor().label("battery"), "bat 38%");
|
||||||
|
session.execute("tab.previous");
|
||||||
|
assert_eq!(session.editor().label("battery"), "bat 38%");
|
||||||
|
let update = |text: &str| PluginUpdate {
|
||||||
|
request_id: None,
|
||||||
|
effects: vec![UpdateEffect::Label {
|
||||||
|
name: "battery".into(),
|
||||||
|
text: text.into(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
!session
|
||||||
|
.apply_update("generic_fixture", update("bat 38%"))
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
let rejected = PluginUpdate {
|
||||||
|
request_id: None,
|
||||||
|
effects: vec![
|
||||||
|
UpdateEffect::Label {
|
||||||
|
name: "battery".into(),
|
||||||
|
text: "must not leak".into(),
|
||||||
|
},
|
||||||
|
UpdateEffect::SemanticTokens {
|
||||||
|
buffer_id: session.editor().buffer_id(),
|
||||||
|
revision: session.editor().text_revision(),
|
||||||
|
tokens: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert!(session.apply_update("generic_fixture", rejected).is_err());
|
||||||
|
assert_eq!(session.editor().label("battery"), "bat 38%");
|
||||||
|
let message = session.editor.message().to_owned();
|
||||||
|
for effects in [
|
||||||
|
vec![
|
||||||
|
UpdateEffect::Message {
|
||||||
|
text: "must not leak".into(),
|
||||||
|
},
|
||||||
|
UpdateEffect::Label {
|
||||||
|
name: "battery".into(),
|
||||||
|
text: "changed".into(),
|
||||||
|
},
|
||||||
|
UpdateEffect::Label {
|
||||||
|
name: "bad-name".into(),
|
||||||
|
text: "invalid".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
UpdateEffect::Label {
|
||||||
|
name: "battery".into(),
|
||||||
|
text: "x".repeat(2 * 1024 * 1024),
|
||||||
|
},
|
||||||
|
UpdateEffect::Label {
|
||||||
|
name: "workspace".into(),
|
||||||
|
text: "x".repeat(2 * 1024 * 1024),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
session
|
||||||
|
.apply_update(
|
||||||
|
"generic_fixture",
|
||||||
|
PluginUpdate {
|
||||||
|
request_id: None,
|
||||||
|
effects
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(session.editor.label("battery"), "bat 38%");
|
||||||
|
assert_eq!(session.editor.label("workspace"), "");
|
||||||
|
assert_eq!(session.editor.message(), message);
|
||||||
|
}
|
||||||
|
session.editor.execute("diff");
|
||||||
|
assert!(session.editor.mutation_blocked());
|
||||||
|
assert!(
|
||||||
|
session
|
||||||
|
.apply_update("generic_fixture", update("bat\t39%\n"))
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(session.editor().label("battery"), "bat�39%�");
|
||||||
|
assert_eq!(session.editor().lines(), [""]);
|
||||||
|
assert!(session.apply_update("generic_fixture", update("")).unwrap());
|
||||||
|
assert_eq!(session.editor().label("battery"), "");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn async_documents_include_unnamed_hidden_versions_real_saves_renames_and_closes() {
|
fn async_documents_include_unnamed_hidden_versions_real_saves_renames_and_closes() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
|
|||||||
+3170
-471
File diff suppressed because it is too large
Load Diff
+101
@@ -184,6 +184,107 @@ fn typed_diff_edits_unsaved_working_after_and_preserves_undo_on_explicit_close()
|
|||||||
assert_eq!(fixture.git(&["status", "--porcelain"]), "M buffer.rs");
|
assert_eq!(fixture.git(&["status", "--porcelain"]), "M buffer.rs");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tabs_keep_repository_workers_and_hidden_comparisons_independent() {
|
||||||
|
let first = Fixture::new();
|
||||||
|
let second = Fixture::new();
|
||||||
|
for (fixture, before, after) in [
|
||||||
|
(&first, "first before\n", "first after\n"),
|
||||||
|
(&second, "second before\n", "second after\n"),
|
||||||
|
] {
|
||||||
|
fixture.git(&["init", "-q"]);
|
||||||
|
fs::write(fixture.0.join("repo/source.txt"), before).unwrap();
|
||||||
|
fixture.git(&["add", "source.txt"]);
|
||||||
|
fixture.git(&["commit", "-qm", "base"]);
|
||||||
|
fs::write(fixture.0.join("repo/source.txt"), after).unwrap();
|
||||||
|
}
|
||||||
|
let first_path = first.0.join("repo/source.txt");
|
||||||
|
let second_path = second.0.join("repo/source.txt");
|
||||||
|
let config = first.0.join("config.rb");
|
||||||
|
fs::write(
|
||||||
|
&config,
|
||||||
|
"Vamp.configure do |config|\n config.bind 'ctrl+option+n', config.core.tab.new\n config.bind 'ctrl+option+w', config.core.tab.close\nend\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut session =
|
||||||
|
EditorSession::open(Some(first_path.clone()), vec!["text".into()], Some(&config)).unwrap();
|
||||||
|
assert!(session.editor().startup_diagnostic().is_none());
|
||||||
|
session.execute("diff");
|
||||||
|
session.handle(Input::CtrlAlt('n'));
|
||||||
|
session.execute(&format!("edit {}", second_path.display()));
|
||||||
|
session.execute("diff");
|
||||||
|
wait_until(&mut session, |session| {
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.diff()
|
||||||
|
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
|
||||||
|
});
|
||||||
|
session.handle(Input::CtrlAlt('h'));
|
||||||
|
assert_eq!(session.editor().tab_index(), 0);
|
||||||
|
wait_until(&mut session, |session| {
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.diff()
|
||||||
|
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
|
||||||
|
});
|
||||||
|
type_query(&mut session, &first.git(&["rev-parse", "HEAD"]));
|
||||||
|
session.handle(Input::Down);
|
||||||
|
session.handle(Input::Enter);
|
||||||
|
session.handle(Input::Enter);
|
||||||
|
session.handle(Input::CtrlAlt('l'));
|
||||||
|
assert_eq!(session.editor().tab_index(), 1);
|
||||||
|
type_query(&mut session, &second.git(&["rev-parse", "HEAD"]));
|
||||||
|
session.handle(Input::Down);
|
||||||
|
session.handle(Input::Enter);
|
||||||
|
session.handle(Input::Enter);
|
||||||
|
wait_until(&mut session, |session| {
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.diff()
|
||||||
|
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
|
||||||
|
});
|
||||||
|
let comparison = session
|
||||||
|
.editor()
|
||||||
|
.diff()
|
||||||
|
.unwrap()
|
||||||
|
.comparison
|
||||||
|
.as_ref()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
comparison.root,
|
||||||
|
second.0.join("repo").canonicalize().unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(comparison.files[0].before, ["second before"]);
|
||||||
|
assert_eq!(comparison.files[0].after, ["second after", ""]);
|
||||||
|
session.handle(Input::CtrlAlt('h'));
|
||||||
|
wait_until(&mut session, |session| {
|
||||||
|
session
|
||||||
|
.editor()
|
||||||
|
.diff()
|
||||||
|
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
|
||||||
|
});
|
||||||
|
let comparison = session
|
||||||
|
.editor()
|
||||||
|
.diff()
|
||||||
|
.unwrap()
|
||||||
|
.comparison
|
||||||
|
.as_ref()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
comparison.root,
|
||||||
|
first.0.join("repo").canonicalize().unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(comparison.files[0].before, ["first before"]);
|
||||||
|
assert_eq!(comparison.files[0].after, ["first after", ""]);
|
||||||
|
session.handle(Input::CtrlAlt('l'));
|
||||||
|
session.handle(Input::CtrlAlt('w'));
|
||||||
|
assert_eq!(session.editor().tab_count(), 1);
|
||||||
|
assert!(session.editor().diff().is_some());
|
||||||
|
session.execute("diff.close");
|
||||||
|
assert_eq!(session.editor().path(), Some(first_path.as_path()));
|
||||||
|
assert_eq!(session.editor().lines(), ["first after", ""]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn editable_diff_navigation_skips_omitted_lines_without_expanding_excerpts() {
|
fn editable_diff_navigation_skips_omitted_lines_without_expanding_excerpts() {
|
||||||
use vamp::diff::DisplayRow;
|
use vamp::diff::DisplayRow;
|
||||||
|
|||||||
+1586
-8
File diff suppressed because it is too large
Load Diff
@@ -534,19 +534,6 @@ fn jj_catalog_and_working_comparisons_prefer_jj_and_never_snapshot_or_merge_oper
|
|||||||
"commit_id",
|
"commit_id",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
let change = jj(
|
|
||||||
&active,
|
|
||||||
&[
|
|
||||||
"--ignore-working-copy",
|
|
||||||
"--at-operation=@",
|
|
||||||
"log",
|
|
||||||
"--no-graph",
|
|
||||||
"-r",
|
|
||||||
"@",
|
|
||||||
"-T",
|
|
||||||
"change_id",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
jj(&active, &["bookmark", "create", "baseline"]);
|
jj(&active, &["bookmark", "create", "baseline"]);
|
||||||
jj(&active, &["new"]);
|
jj(&active, &["new"]);
|
||||||
fs::write(active.join("file"), "disk\n").unwrap();
|
fs::write(active.join("file"), "disk\n").unwrap();
|
||||||
@@ -564,8 +551,6 @@ fn jj_catalog_and_working_comparisons_prefer_jj_and_never_snapshot_or_merge_oper
|
|||||||
r.target == RevisionTarget::Commit(id.clone()) && r.category == Category::Revisions
|
r.target == RevisionTarget::Commit(id.clone()) && r.category == Category::Revisions
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(baseline.label.contains(&id) && baseline.label.contains(&change));
|
|
||||||
assert_eq!(baseline.description, "jj baseline searchable");
|
|
||||||
assert!(
|
assert!(
|
||||||
catalog
|
catalog
|
||||||
.entries
|
.entries
|
||||||
@@ -641,27 +626,6 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
|||||||
bare.to_str().unwrap(),
|
bare.to_str().unwrap(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
// Git transport may derive imported change IDs; compare with an independent
|
|
||||||
// CLI import rather than assuming the exporting store's private metadata.
|
|
||||||
let oracle = fixture.dir("oracle");
|
|
||||||
init_jj(&oracle, false);
|
|
||||||
jj(
|
|
||||||
&oracle,
|
|
||||||
&["git", "remote", "add", "origin", bare.to_str().unwrap()],
|
|
||||||
);
|
|
||||||
jj(&oracle, &["git", "fetch", "--remote", "origin"]);
|
|
||||||
let change = jj(
|
|
||||||
&oracle,
|
|
||||||
&[
|
|
||||||
"--ignore-working-copy",
|
|
||||||
"log",
|
|
||||||
"--no-graph",
|
|
||||||
"-r",
|
|
||||||
&ancestor,
|
|
||||||
"-T",
|
|
||||||
"change_id",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let active = fixture.dir("active");
|
let active = fixture.dir("active");
|
||||||
init_jj(&active, false);
|
init_jj(&active, false);
|
||||||
fs::write(active.join("file"), "local\n").unwrap();
|
fs::write(active.join("file"), "local\n").unwrap();
|
||||||
@@ -709,8 +673,6 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
|
|||||||
&& r.category == Category::Revisions
|
&& r.category == Category::Revisions
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(historic.label.contains(&ancestor) && historic.label.contains(&change));
|
|
||||||
assert_eq!(historic.description, "remote historic needle");
|
|
||||||
let comparison = compare_loaded(&mut repository, historic, &remote, &[]).unwrap();
|
let comparison = compare_loaded(&mut repository, historic, &remote, &[]).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
changed(&comparison, Path::new("file")).before,
|
changed(&comparison, Path::new("file")).before,
|
||||||
|
|||||||
Reference in New Issue
Block a user