Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a28444a9f2 | |||
| d8cfd27ee6 | |||
| e310b76794 | |||
| 074df97c02 | |||
| 00207b5526 | |||
| a5f92452b3 | |||
| f942f881e6 | |||
| 011dd7cc49 | |||
| 0a01d9a361 | |||
| 5d75964ee2 |
@@ -1,10 +1,8 @@
|
||||
- [x] syntax highlighting for Honey (honeylang)
|
||||
- honeylang treesitter project located at `/Users/valdemar/Developer/Personal/Languages/tree-sitter-honey`.
|
||||
- [x] bug: `x` cut action does not copy the hovered character if not in selection mode, though should always copy.
|
||||
- [x] make diff comments word wrap / line wrap by default
|
||||
- [x] currently, a review comment seems to be attached even if the user doesn't "save" or write the comment buffer (e.g. using `:w`). just closing it with escape still seems to write it to the db.
|
||||
- comments should be discardable
|
||||
- [x] cycle previous commands and search text in command and search input when pressing arrows up/down or ctrl+j/k.
|
||||
- [x] command autocompletion via `tab` (multiple presses cycles candidates)
|
||||
- [x] fix ruby linting for the vamp config file (see attached image)
|
||||
- [x] execute terminal commands asynchronously using the `!` prefix (e.g. `!pwd`) and display the output in a floating window.
|
||||
- while waiting on executed commands, show their delayed labels/spinners in one independently styled element just left of the battery status in the top bar (top-right corner)
|
||||
- indicate also the command that we're waiting on, maybe something like the first three characters (followed by ellipses if truncated), e.g. `pwd <spinner>` or if the command was `echo $PATH`, then `ech... <spinner>`.
|
||||
- [ ] diff view: GitHub code review integration after the local review UX is usable.
|
||||
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
|
||||
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
|
||||
|
||||
@@ -11,6 +11,7 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
|
||||
- `editor`: owns shared buffer lifecycle/text/saved state/undo history and independent tab/pane layouts, focused views, modes, selections, searches, explorers, and comparisons; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, key-sequence/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||
- `chrome`: owns validated top/bottom bar anchor groups, segment title templates and padding/edge options, builtin defaults, named-value validation, and typed command/search popup anchors. Both bars accept the same segments and layout options. It has no terminal geometry or render callbacks.
|
||||
- `battery`: reads native charge off the input thread, sampling immediately and every 30 seconds through an interruptible worker. macOS CoreFoundation references are type-checked and owned Copy/Create values released; Linux reads system battery sysfs and Windows reads native power status. A one-slot atomic mailbox retains the latest sample; absent/invalid readings clear cached presentation instead of inventing a percentage. Session starts collection only while an unoverridden battery segment is configured.
|
||||
- `shell`: runs explicit `:!` commands off the input thread with inherited environment, closed stdin, and bounded stdout/stderr capture. Each command has a private Unix process group, killed and reaped on worker drop or shell completion. Session owns concurrent workers and combines their delayed spinners in one shared status segment; Editor owns the read-only output popup, queued completed results, and modal scrolling/dismissal state. No repository-command environment overrides or timeouts apply.
|
||||
- `blame`: owns local current-line Git attribution against the supplied in-memory text and a cancellable background worker. Git-backed jj uses its working-copy parent's commit through an isolated temporary Git store; active refs/checkouts remain untouched.
|
||||
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
||||
Virtual diff leaves retain their original catalog input index for caller-owned annotations. Normal scanned leaves accept optional repository status and old-path metadata without filtering unchanged files; missing tracked deletions add virtual leaves and required directories. Metadata-only updates preserve query, selection, expansion, and view without rebuilding the catalog.
|
||||
|
||||
+31
-6
@@ -117,9 +117,11 @@ config.bind "leader s", config.core.diff.orientation.toggle
|
||||
|
||||
`config.bar.top` and `config.bar.bottom` each accept ordered `left`, `center`, and `right` arrays of segment names. Both bars accept every builtin and custom segment, with the same anchors, title templates, margins, padding, styled edges, and collision handling. Left and right groups anchor to their respective edges; the center group is centered on the whole row, not on the gap remaining between the other groups. Array order is display order within each group. Omitted groups and segment options retain that bar's defaults; an empty array removes that group.
|
||||
|
||||
The renderer inserts one unstyled blank column between visible items in each group, including tab titles. Hidden items contribute no separator; there is no separator before the first or after the last item. Separator widths count toward anchoring and collision handling. Plain-text segments default to no padding or margins, so adjacent labels do not double their spacing.
|
||||
|
||||
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:
|
||||
Defaults put `left: [:tabs]` and `right: [:shell, :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|
|
||||
@@ -127,10 +129,10 @@ Vamp.configure do |config|
|
||||
left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer],
|
||||
center: [], right: [:language, :lsp, :position],
|
||||
mode: { padding: 1, leading_edge: "", trailing_edge: "▓░" },
|
||||
position: { padding: 1, leading_margin: 1, trailing_margin: 0, leading_edge: "░▓", trailing_edge: "" }
|
||||
position: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
|
||||
}
|
||||
config.bar.bottom = {
|
||||
left: [:tabs], center: [], right: [:battery],
|
||||
left: [:tabs], center: [], right: [:shell, :battery],
|
||||
tabs: {
|
||||
title: "{index} {view}{name}{modified}", padding: 1,
|
||||
leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: ""
|
||||
@@ -140,7 +142,7 @@ Vamp.configure do |config|
|
||||
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.
|
||||
Builtins are `:tabs`, `:battery`, `:shell`, `:mode`, `:path`, `:position`, `:language`, `:lsp`, `:diagnostics`, `:selections`, `:count`, `:pending`, and `:buffer`, in either bar. They provide tab titles, battery charge, running shell command/spinner, 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.
|
||||
|
||||
@@ -150,9 +152,9 @@ Each segment's `title` is a literal template supporting only `{index}`, `{view}`
|
||||
|
||||
`:lsp` displays only ready servers assigned to the focused buffer, in configured stack order joined with ` + `. Disabled, initializing, failed, or unassigned servers do not appear. Names come from the initialization reply's `serverInfo.name`, falling back to the configured executable basename; no arbitrary named label can make an inactive server appear active.
|
||||
|
||||
`margin` adds unstyled space outside both edges of a segment, unlike the styled internal `padding`. `leading_margin` and `trailing_margin` override their respective sides. All accept integers from `0` through `64`. Margins default to zero except the bottom bar's `:position`, which has one leading column and no trailing margin. LSP has no default margins. For example, `config.bar.bottom = { position: { leading_margin: 0 } }` removes position's external spacing without changing its padding. Margins count toward anchor placement and collision handling; empty segments consume no margin, and margins yield along with padding and edges when only clipped content fits.
|
||||
`margin` adds unstyled space outside both edges of a segment, unlike the styled internal `padding`. `leading_margin` and `trailing_margin` override their respective sides. All accept integers from `0` through `64`; all margins default to zero. Explicit margins are additional to the renderer's one-column separator, so leave them at zero for normal inter-item spacing. Margins count toward anchor placement and collision handling; empty segments consume no margin, and margins yield along with padding and edges when only clipped content fits.
|
||||
|
||||
`padding` adds spaces inside the segment's styling, on both sides of its title, and accepts integers from `0` through `64` (default `1`). `leading_edge` and `trailing_edge` are separate styled glyph strings, not padding; `""` removes an edge. Tabs default to `"░▓"` / `"▓░"` edges; mode has only the trailing `"▓░"` edge, position and battery only the leading `"░▓"` edge, and other segments have none. `first_leading_edge` optionally overrides the first occurrence's leading glyphs: for tabs this is the actual first tab, not the first tab remaining after clipping; other segments have one occurrence. Tabs default this override to `""`, suppressing the first tab's left fade. Unicode widths are measured in terminal cells. Edge strings must be single-line and contain no control characters. Unknown style keys, invalid templates, and wrong types reject configuration.
|
||||
`padding` adds spaces inside the segment's styling, on both sides of its title, and accepts integers from `0` through `64`. It defaults to `0` for plain-text and custom segments and `1` for tabs, mode, position, battery, and shell status. Internal padding is separate from the inter-item separator; explicit padding can deliberately add more whitespace. `leading_edge` and `trailing_edge` are separate styled glyph strings, not padding; `""` removes an edge. Tabs and shell status 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, margins, padding, and edges. Names match `[A-Za-z_][A-Za-z0-9_]*`. Labels are plain text: control characters are replaced rather than interpreted as terminal escapes. The same named value can appear in either bar.
|
||||
|
||||
@@ -160,6 +162,8 @@ Any other segment name is a custom named value, such as `:build_status`. Set ini
|
||||
|
||||
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.
|
||||
|
||||
`:shell` is one shared segment containing the abbreviations and animated spinners for all running `:!COMMAND` processes. Each entry appears only after its command has run for 200 ms, avoiding flashes for short commands. Completed output is shown immediately or queued behind the current result window. The segment hides when idle. It defaults immediately left of `:battery`, with its own styled edges and the standard one-column separator; move it to any anchor group or omit it without affecting command execution. A nonempty label named `shell` overrides the builtin display; clearing it restores the running indicators.
|
||||
|
||||
### Command and search popup anchors
|
||||
|
||||
Configure the two input windows independently:
|
||||
@@ -199,6 +203,8 @@ Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i` to `config.co
|
||||
|
||||
`config.core.backspace_word` and `config.core.backspace_line` are typed actions eligible in Insert, Command, and Search. Ctrl+u defaults to `config.core.backspace_line` in all three modes on every platform; it can be rebound or disabled with the ordinary per-mode binding interface. macOS additionally defaults Option+Backspace and Command+Backspace to word and line deletion respectively. Any platform can explicitly bind these actions to supported keys; for example, `config.bind "ctrl+w", config.core.backspace_word, mode: config.modes.insert`. Word deletion follows Unicode word boundaries after skipping trailing whitespace; line deletion removes the caret-to-logical-line-start prefix, not the suffix or newline. Active snippets confine both actions to their argument. macOS prompt and filter queries also recognize these modified Backspace keys.
|
||||
|
||||
Command-mode Tab defaults to `config.core.command_complete`, and Shift+Tab to `config.core.command_complete_previous`: complete a case-sensitive command-name prefix, then cycle alphabetical matches forward/backward with wraparound. Shift+Tab starts at the last match; switching direction retains the original prefix. Core commands, shorthand aliases, and enabled plugin commands share the candidate list; arguments are not completed. For example, `config.bind "ctrl+n", config.core.command_complete, mode: config.modes.command` adds another forward completion key. Override or disable Tab and Shift+Tab independently; Search-mode Tab remains literal text.
|
||||
|
||||
Configuration accepts the Option/Alt and Command/Super Backspace names on every platform, but terminal modifier-event translation for these keys is macOS-only. Native modifier events must deliver Option or Super (Command in Kitty CSI-u); Meta is not Command. Ghostty's default Command+Backspace encoding is Ctrl+u (`\x15`), which invokes the same line-deletion action without a Ghostty override. It remains a distinct Ctrl+u binding, not a reconstructed Command modifier. Legacy ESC+DEL supports Option+Backspace without keyboard protocol negotiation.
|
||||
|
||||
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.
|
||||
@@ -225,6 +231,7 @@ Normal and Visual Ctrl+u/Ctrl+d invoke `config.core.scroll_step_up` / `config.co
|
||||
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
|
||||
| Normal | `join_lines`, `undo`, `redo` |
|
||||
| Normal, Visual | `command` |
|
||||
| Command | `command_complete`, `command_complete_previous` |
|
||||
| Normal | `copy_line`, `delete_line`, `change_line` |
|
||||
| Normal | `change_to_line_end`, `delete_to_line_end`, `copy_to_line_end`, `substitute_character`, `delete_word_forward`, `delete_word_backward`, `change_word_forward`, `change_word_backward` |
|
||||
| Normal, Visual | `replace_character` |
|
||||
@@ -393,6 +400,24 @@ Only `command`, `extensions`, `initialization_options`, `settings`, and `servers
|
||||
|
||||
Install Solargraph yourself with `gem install solargraph` in the project's Ruby environment. The Ruby preset launches `["solargraph", "stdio"]`, following [Solargraph's stdio integration](https://solargraph.org/guides/language-server), and uses [YARD annotations](https://solargraph.org/guides/yard) for type information. Explicit `command` overrides replace the entire argv: for a bundled installation, use `config.lsp["ruby"] = { command: ["bundle", "exec", "solargraph", "stdio"] }`; Vamp retains the `rb` extension but does not append `stdio` to your override.
|
||||
|
||||
**Config-file lint style:** Solargraph's RuboCop reporter discovers `.rubocop.yml` from the Ruby file's directory, not Vamp's workspace. A config under `~/.config/vamp/` therefore does not inherit this repository's policy. To keep double quotes, multiline trailing argument commas, commented-out settings without a space after `#`, and no required frozen-string header, place this `.rubocop.yml` beside `config.rb` (also for a custom `--config` path):
|
||||
|
||||
```yaml
|
||||
Style/StringLiterals:
|
||||
EnforcedStyle: double_quotes
|
||||
|
||||
Style/TrailingCommaInArguments:
|
||||
EnforcedStyleForMultiline: comma
|
||||
|
||||
Style/FrozenStringLiteralComment:
|
||||
Enabled: false
|
||||
|
||||
Layout/LeadingCommentSpace:
|
||||
Enabled: false
|
||||
```
|
||||
|
||||
Syntax errors and other lint checks remain enabled. Restart Vamp after changing the policy so Solargraph reloads it; the Ruby config itself need not change.
|
||||
|
||||
**Python (Astral):** install [ty](https://docs.astral.sh/ty/editors/) and [Ruff](https://docs.astral.sh/ruff/editors/setup/) yourself, for example with `uv tool install ty` and `uv tool install ruff`. The Python preset sends the same documents to both servers, uses ty for hover/definition, and merges type errors with Ruff lint diagnostics. Clearing or losing one server preserves the other's diagnostics. Formatting and code actions remain unsupported by Vamp.
|
||||
|
||||
Use `servers` to customize the stack independently; order selects the hover/definition/completion server:
|
||||
|
||||
@@ -43,6 +43,8 @@ Rust tests live under [`tests/`](../tests/). Private unit tests are in [`tests/u
|
||||
|
||||
- Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity.
|
||||
- Prompt-history checks cover separate session-wide command/search entries, Unicode draft restoration, editable recalls, clamped/empty/bounded history, duplicate/empty/cancelled submissions, remapped/disabled keys, and preserved Visual/read-only diff targets. Live PTY smoke exercises arrows and raw Ctrl+k/j, restores drafts, accepts a recalled search with Enter, and checks command/search isolation and unchanged source bytes.
|
||||
- Command-completion checks cover forward/backward alphabetical cycling/wrap across core names, aliases and live plugin registrations, reverse-first completion and direction changes retaining the prefix, explicit execution, edits/history/cancel resets, empty/unknown/Unicode prefixes, untouched arguments, remapped/disabled keys, and preserved Visual/read-only diff targets and literal search tabs. Live PTY smoke cycles `tab.*` in both directions through wraparound, submits a completed `tab.new`, and exits with unchanged source bytes.
|
||||
- Shell-command checks use file-gated subprocesses to prove editing remains live, stdout/stderr/status and cwd handling, null stdin, output bounds, launch/worker errors, and process-group cleanup on completion/drop. Spinner timing uses an injected `Instant`; render checks cover scrolling, dismissal, hidden source caret, and tiny/offset windows. Live PTY smoke gates completion, saves an edit while pending, then observes both output streams and exit status and dismisses the popup.
|
||||
- Visual-command checks preserve directed characterwise/linewise ranges and multiple carets through command cancellation and plugin dispatch, exercise remapped command entry, and submit `:diff.comment` on either historical side. Live PTY smoke selects lines, cancels a prompt, then runs `:diff.comment` directly and verifies the saved original range without using the palette.
|
||||
- Presentation regressions render retained characterwise/linewise highlights during command entry in ordinary, split-diff, and unified views. Floating-review checks preserve source rectangles and the workspace layout, place the caret inside the overlay, retain explicitly saved text across tab switches, and restore source focus on Escape or pane close.
|
||||
- Review prose checks render long comments and summaries with wrapping enabled by default in split/unified views, preserve exact single-line text and source wrapping, and retain an explicit wrap toggle on reopening. Live PTY smoke displays a long comment across continuation rows, saves it, verifies exact text through the review CLI, and reopens it still wrapped.
|
||||
|
||||
+9
-1
@@ -51,7 +51,7 @@ Indicators refresh asynchronously. Typing and undo/redo retain the last complete
|
||||
| Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End at every caret outside snippets; Esc returns to Normal, retaining carets |
|
||||
| Insert | Ctrl+n requests completion at the primary caret or cycles next; Ctrl+j/k cycles next/previous while completion is open |
|
||||
| Insert, active snippet | Type to replace the highlighted argument and its mirrors; Tab / Shift+Tab moves next/previous |
|
||||
| Command | Enter executes; ↑/↓ or Ctrl+k/j browse history; Backspace edits; Esc cancels |
|
||||
| Command | Tab / Shift+Tab completes/cycles command names forward/backward; Enter executes; ↑/↓ or Ctrl+k/j browse history; Backspace edits; Esc cancels |
|
||||
| Search | Type a literal query; Enter accepts the current occurrence or finds the next match; ↑/↓ or Ctrl+k/j browse history; Backspace edits; Esc cancels |
|
||||
| Insert, Command, Search | Ctrl+u deletes backward to the logical line start |
|
||||
| Insert, Command, Search (macOS) | Option+Backspace deletes backward by word; Command+Backspace deletes backward to the logical line start |
|
||||
@@ -194,8 +194,16 @@ Submitting an empty search repeats the last query. Esc cancels without moving th
|
||||
|
||||
Up/Ctrl+k recalls older entries; Down/Ctrl+j recalls newer entries and restores the unfinished input after the newest. Navigation stops at either end without executing a command or moving search targets. Recalled text is editable without changing stored entries. Command and search histories are separate, shared across buffers/panes/tabs, and retained only for this editor session, up to 100 entries each. Enter records nonempty submissions, including unknown commands and searches with no matches; consecutive duplicates are skipped. Empty submissions and cancelled drafts are not recorded. Ctrl+j no longer submits Command/Search input; use Enter.
|
||||
|
||||
Tab replaces the command-name prefix with the first case-sensitive match in alphabetical order; repeated presses cycle forward through the same matches and wrap. Shift+Tab cycles backward, starting at the last match when no cycle is active. Switching direction retains the original prefix. Candidates include canonical core commands, shorthand aliases, and commands from currently enabled plugins. Empty input cycles all command names. Typing, deleting, or browsing history starts a fresh completion cycle. Completion only changes the prompt: Enter still executes, and Escape cancels. Arguments (including paths) are not completed; Tab and Shift+Tab leave them unchanged. Search-mode Tab remains literal text. Rebind or disable forward/backward completion with `config.core.command_complete` / `config.core.command_complete_previous` in Command mode.
|
||||
|
||||
Command input works in both Normal and Visual modes, including read-only diff panes. The original selection stays highlighted while typing. Enter dispatches with its ranges, direction, and linewise shape intact; individual commands decide whether to consume or retain them. Escape returns to the original Visual selection without executing. For example, select lines with `V`, then type `:diff.comment` and press Enter—no command palette is needed. `config.core.command` can also be bound in Visual mode.
|
||||
|
||||
Use `:!COMMAND` (for example `:!pwd` or `:!echo "$PATH"`) to run shell commands asynchronously while continuing to edit. Vamp runs `$SHELL -c COMMAND`, falling back to `/bin/sh`, in the editor's project directory with the inherited environment and closed stdin. Shell quoting, pipes, and redirection work normally; interactive terminal programs are not supported. Commands have the same permissions as Vamp, including file and network access. Submit additional `:!` commands while others are running to execute them concurrently.
|
||||
|
||||
While running, the default top bar uses one independently styled segment immediately left of the battery for all pending commands. Each entry shows the first three command graphemes, `...` when truncated, and an animated spinner after a 200 ms delay. Completion opens a read-only floating window with the command, exit status (or launch error), stdout, and separately marked stderr. Further completed results queue without replacing the visible window; Enter/Escape dismisses the current result and opens the next, or resumes the underlying editing mode when none remain. Arrows/Ctrl+h/j/k/l and the mouse wheel scroll; Home resets and End goes to the last line. Output is decoded as UTF-8 with replacements; controls are displayed as text, never interpreted as terminal escapes. Capture retains up to 4 MiB per stream with an explicit truncation notice. There is no command timeout; exiting Vamp kills all running command process groups, and background jobs in each group are also stopped when their command exits. Shell execution currently requires Unix.
|
||||
|
||||
In the shell output window, your configured `config.core.word_wrap.toggle` binding toggles soft wrapping, including leader and multi-key sequences. Bindings use Normal mode while the popup is focused; there is no additional default shortcut. This does not change output bytes or the source buffer's wrap setting. Wrapping starts off for each command; toggling resets scrolling to the start. With wrapping on, vertical scrolling follows visual rows and horizontal scrolling is disabled.
|
||||
|
||||
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.
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ Vamp.configure do |config|
|
||||
config.resolve_history_layout = "columns" # or "base_below" for left/right above the base
|
||||
# config.popup.command = { anchor: :bottom_left }
|
||||
# config.popup.search = { anchor: :center }
|
||||
# Status above, tabs and live battery below; either bar accepts all segments.
|
||||
# Status above, tabs, live battery and pending shell command below.
|
||||
config.bar.top = {
|
||||
left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer],
|
||||
center: [], right: [:language, :lsp, :position],
|
||||
@@ -29,7 +29,7 @@ Vamp.configure do |config|
|
||||
position: { padding: 1, leading_edge: "░▓", trailing_edge: "" }
|
||||
}
|
||||
config.bar.bottom = {
|
||||
left: [:tabs], center: [], right: [:battery],
|
||||
left: [:tabs], center: [], right: [:shell, :battery],
|
||||
tabs: {
|
||||
title: "{index} {view}{name}{modified}", padding: 1,
|
||||
leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: ""
|
||||
|
||||
+9
-24
@@ -102,7 +102,7 @@ pub struct ChromeLine {
|
||||
pub segments: BTreeMap<String, SegmentConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SegmentConfig {
|
||||
pub title: String,
|
||||
pub padding: usize,
|
||||
@@ -112,28 +112,16 @@ pub struct SegmentConfig {
|
||||
pub first_leading_edge: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for SegmentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: String::new(),
|
||||
padding: 1,
|
||||
margin: [0; 2],
|
||||
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()],
|
||||
right: vec!["shell".into(), "battery".into()],
|
||||
segments: BTreeMap::from([
|
||||
("tabs".into(), default_segment("tabs")),
|
||||
("battery".into(), default_segment("battery")),
|
||||
("shell".into(), default_segment("shell")),
|
||||
]),
|
||||
}
|
||||
}
|
||||
@@ -148,13 +136,7 @@ impl ChromeLine {
|
||||
right: vec!["language".into(), "lsp".into(), "position".into()],
|
||||
segments: DEFAULT_BOTTOM_SEGMENTS
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let mut segment = default_segment(name);
|
||||
if *name == "position" {
|
||||
segment.margin[0] = 1;
|
||||
}
|
||||
((*name).into(), segment)
|
||||
})
|
||||
.map(|name| ((*name).into(), default_segment(name)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
@@ -212,12 +194,15 @@ impl ChromeLine {
|
||||
|
||||
fn default_segment(name: &str) -> SegmentConfig {
|
||||
let mut segment = SegmentConfig::default();
|
||||
if matches!(name, "tabs" | "position" | "battery") {
|
||||
if matches!(name, "tabs" | "position" | "battery" | "shell") {
|
||||
segment.leading_edge = "░▓".into();
|
||||
}
|
||||
if matches!(name, "tabs" | "mode") {
|
||||
if matches!(name, "tabs" | "mode" | "shell") {
|
||||
segment.trailing_edge = "▓░".into();
|
||||
}
|
||||
if matches!(name, "tabs" | "mode" | "position" | "battery" | "shell") {
|
||||
segment.padding = 1;
|
||||
}
|
||||
if name == "tabs" {
|
||||
segment.title = "{index} {view}{name}{modified}".into();
|
||||
segment.first_leading_edge = Some(String::new());
|
||||
|
||||
+267
-1
@@ -137,6 +137,14 @@ pub struct StartupDiagnostic {
|
||||
max_column: usize,
|
||||
}
|
||||
|
||||
pub(crate) struct ShellOutput {
|
||||
pub(crate) title: String,
|
||||
pub(crate) content: StartupDiagnostic,
|
||||
pub(crate) wrapping: bool,
|
||||
pub(crate) wrapped_rows: Cell<usize>,
|
||||
pub(crate) wrapped_lines: std::cell::RefCell<(usize, Vec<(usize, crate::wrap::Segment)>)>,
|
||||
}
|
||||
|
||||
pub struct Hover {
|
||||
text: String,
|
||||
line_starts: Vec<usize>,
|
||||
@@ -805,6 +813,16 @@ core_actions![
|
||||
Action::Submit,
|
||||
&[Mode::Command, Mode::Search],
|
||||
),
|
||||
(
|
||||
"core.command_complete",
|
||||
Action::CommandComplete,
|
||||
&[Mode::Command],
|
||||
),
|
||||
(
|
||||
"core.command_complete_previous",
|
||||
Action::CommandCompletePrevious,
|
||||
&[Mode::Command],
|
||||
),
|
||||
(
|
||||
"core.palette",
|
||||
Action::Palette,
|
||||
@@ -1584,6 +1602,15 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
|
||||
.insert(key, Binding::Action(action));
|
||||
}
|
||||
}
|
||||
for (key, action) in [
|
||||
(Input::Char('\t'), CommandComplete),
|
||||
(Input::BackTab, CommandCompletePrevious),
|
||||
] {
|
||||
bindings
|
||||
.entry(Mode::Command)
|
||||
.or_default()
|
||||
.insert(key, Binding::Action(action));
|
||||
}
|
||||
for mode in [Mode::Command, Mode::Search] {
|
||||
for (key, action) in [(Input::Up, Up), (Input::Ctrl('k'), Up), (Input::Down, Down)] {
|
||||
bindings
|
||||
@@ -1751,6 +1778,7 @@ struct ViewState {
|
||||
search_highlight: bool,
|
||||
search_match: Option<SearchMatch>,
|
||||
command_line: String,
|
||||
command_completion_prefix: Option<String>,
|
||||
prompt_history_index: Option<usize>,
|
||||
prompt_draft: String,
|
||||
command_from_visual: bool,
|
||||
@@ -1775,6 +1803,7 @@ impl ViewState {
|
||||
search_highlight: false,
|
||||
search_match: None,
|
||||
command_line: String::new(),
|
||||
command_completion_prefix: None,
|
||||
prompt_history_index: None,
|
||||
prompt_draft: String::new(),
|
||||
command_from_visual: false,
|
||||
@@ -1799,6 +1828,7 @@ impl ViewState {
|
||||
search_highlight: self.search_highlight,
|
||||
search_match: self.search_match,
|
||||
command_line: self.command_line.clone(),
|
||||
command_completion_prefix: self.command_completion_prefix.clone(),
|
||||
prompt_history_index: self.prompt_history_index,
|
||||
prompt_draft: self.prompt_draft.clone(),
|
||||
command_from_visual: self.command_from_visual,
|
||||
@@ -2201,6 +2231,9 @@ pub struct Editor {
|
||||
language_tab_spaces: BTreeMap<String, Option<NonZeroU8>>,
|
||||
battery_charge: Option<u8>,
|
||||
battery_label: String,
|
||||
shell_label: String,
|
||||
command_output: Option<ShellOutput>,
|
||||
queued_command_outputs: VecDeque<ShellOutput>,
|
||||
git_blame: bool,
|
||||
gutter_style: crate::gutter::GutterStyle,
|
||||
line_blame: Option<(BlameSource, Arc<crate::blame::LineBlame>, String)>,
|
||||
@@ -2317,6 +2350,9 @@ impl Editor {
|
||||
language_tab_spaces: BTreeMap::new(),
|
||||
battery_charge: None,
|
||||
battery_label: String::new(),
|
||||
shell_label: String::new(),
|
||||
command_output: None,
|
||||
queued_command_outputs: VecDeque::new(),
|
||||
git_blame: true,
|
||||
gutter_style: crate::gutter::GutterStyle::default(),
|
||||
line_blame: None,
|
||||
@@ -4661,6 +4697,9 @@ impl Editor {
|
||||
}
|
||||
|
||||
fn binding_mode(&self) -> Mode {
|
||||
if self.command_output.is_some() {
|
||||
return Mode::Normal;
|
||||
}
|
||||
if self.diff.is_some()
|
||||
&& !self.diff_interacting()
|
||||
&& !matches!(self.active.view.mode, Mode::Command | Mode::Search)
|
||||
@@ -4676,6 +4715,7 @@ impl Editor {
|
||||
&& self.document_actions_available()
|
||||
&& self.startup_diagnostic.is_none()
|
||||
&& self.hover.is_none()
|
||||
&& self.command_output.is_none()
|
||||
&& self.palette.is_none()
|
||||
&& self.completion.is_none()
|
||||
&& !self.file_picker().is_some_and(|picker| picker.focused)
|
||||
@@ -6892,6 +6932,144 @@ impl Editor {
|
||||
pub fn battery_label(&self) -> &str {
|
||||
&self.battery_label
|
||||
}
|
||||
pub(crate) fn shell_label(&self) -> &str {
|
||||
&self.shell_label
|
||||
}
|
||||
|
||||
pub(crate) fn set_shell_label(&mut self, label: String) -> bool {
|
||||
if self.shell_label == label {
|
||||
return false;
|
||||
}
|
||||
self.shell_label = label;
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn command_output(&self) -> Option<&ShellOutput> {
|
||||
self.command_output.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn show_command_output(
|
||||
&mut self,
|
||||
command: String,
|
||||
result: Result<crate::shell::Output, String>,
|
||||
) {
|
||||
let (status, text) = match result {
|
||||
Ok(output) => {
|
||||
let status = output.code.map_or_else(
|
||||
|| "terminated by signal".into(),
|
||||
|code| format!("exit {code}"),
|
||||
);
|
||||
let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
if !output.stderr.is_empty() {
|
||||
if !text.is_empty() && !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push_str("[stderr]\n");
|
||||
text.push_str(&String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
if text.is_empty() {
|
||||
text.push_str("(no output)");
|
||||
}
|
||||
(status, text)
|
||||
}
|
||||
Err(error) => ("failed".into(), error),
|
||||
};
|
||||
// Captured bytes are text, never terminal escape sequences.
|
||||
let lines: Vec<String> = normalize_newlines(&text)
|
||||
.split('\n')
|
||||
.map(|line| {
|
||||
line.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_control() && ch != '\t' {
|
||||
'�'
|
||||
} else {
|
||||
ch
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let max_column = lines
|
||||
.iter()
|
||||
.map(|line| grapheme_count(line).saturating_sub(1))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let command: String = command
|
||||
.chars()
|
||||
.map(|ch| if ch.is_control() { '�' } else { ch })
|
||||
.collect();
|
||||
let output = ShellOutput {
|
||||
title: format!("{status} · !{command}"),
|
||||
wrapping: false,
|
||||
wrapped_rows: Cell::new(1),
|
||||
wrapped_lines: Default::default(),
|
||||
content: StartupDiagnostic {
|
||||
lines,
|
||||
top: 0,
|
||||
left: 0,
|
||||
max_column,
|
||||
},
|
||||
};
|
||||
if self.command_output.is_some() {
|
||||
self.queued_command_outputs.push_back(output);
|
||||
} else {
|
||||
self.command_output = Some(output);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_command_output(&mut self, input: &Input) -> bool {
|
||||
if self.command_output.is_none() {
|
||||
return false;
|
||||
}
|
||||
if self.sequence_pending().is_some() {
|
||||
if matches!(input, Input::Escape | Input::Paste(_)) {
|
||||
self.cancel_sequence();
|
||||
} else {
|
||||
self.handle_sequence(input.clone());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let mode = self.binding_mode();
|
||||
if self.start_sequence(mode, input) {
|
||||
return true;
|
||||
}
|
||||
if matches!(
|
||||
self.bindings.get(&mode).and_then(|keys| keys.get(input)),
|
||||
Some(Binding::Action(Action::WordWrapToggle))
|
||||
) {
|
||||
self.act(Action::WordWrapToggle);
|
||||
return true;
|
||||
}
|
||||
let output = self.command_output.as_mut().unwrap();
|
||||
let rows = if output.wrapping {
|
||||
output.wrapped_rows.get()
|
||||
} else {
|
||||
output.content.lines.len()
|
||||
};
|
||||
let content = &mut output.content;
|
||||
match input {
|
||||
Input::Escape | Input::Enter => {
|
||||
self.command_output = self.queued_command_outputs.pop_front()
|
||||
}
|
||||
Input::Up | Input::Ctrl('k') => content.top = content.top.saturating_sub(1),
|
||||
Input::Down | Input::Ctrl('j') => {
|
||||
content.top = content.top.saturating_add(1).min(rows.saturating_sub(1));
|
||||
}
|
||||
Input::Left | Input::Ctrl('h') if !output.wrapping => {
|
||||
content.left = content.left.saturating_sub(1)
|
||||
}
|
||||
Input::Right | Input::Ctrl('l') if !output.wrapping => {
|
||||
content.left = content.left.saturating_add(1).min(content.max_column);
|
||||
}
|
||||
Input::Home => {
|
||||
content.top = 0;
|
||||
content.left = 0;
|
||||
}
|
||||
Input::End => content.top = rows.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
pub(crate) fn gutter_style(&self) -> crate::gutter::GutterStyle {
|
||||
self.gutter_style
|
||||
}
|
||||
@@ -7953,6 +8131,9 @@ impl Editor {
|
||||
Input::Ctrl(ch) => control_key(ch),
|
||||
input => input,
|
||||
};
|
||||
if self.handle_command_output(&input) {
|
||||
return None;
|
||||
}
|
||||
if self.handle_review_overview(&input) {
|
||||
return None;
|
||||
}
|
||||
@@ -8006,6 +8187,14 @@ impl Editor {
|
||||
|
||||
fn handle_input(&mut self, input: Input) -> Option<Invocation> {
|
||||
let mode = self.binding_mode();
|
||||
if !matches!(
|
||||
self.bindings.get(&mode).and_then(|keys| keys.get(&input)),
|
||||
Some(Binding::Action(
|
||||
Action::CommandComplete | Action::CommandCompletePrevious
|
||||
))
|
||||
) {
|
||||
self.active.view.command_completion_prefix = None;
|
||||
}
|
||||
if self.completion.is_some() && (self.diff().is_none() || self.diff_editing()) {
|
||||
let request = matches!(
|
||||
self.bindings
|
||||
@@ -8367,8 +8556,18 @@ impl Editor {
|
||||
self.pending_sequence.keys.push(input);
|
||||
let bindings = self.sequence_bindings.get(&self.binding_mode());
|
||||
match bindings.and_then(|keys| keys.get(&self.pending_sequence)) {
|
||||
Some(Binding::Action(action)) => return self.act(*action),
|
||||
Some(Binding::Action(action)) => {
|
||||
if self.command_output.is_some() && *action != Action::WordWrapToggle {
|
||||
self.cancel_sequence();
|
||||
return None;
|
||||
}
|
||||
return self.act(*action);
|
||||
}
|
||||
Some(Binding::Command(command)) => {
|
||||
if self.command_output.is_some() {
|
||||
self.cancel_sequence();
|
||||
return None;
|
||||
}
|
||||
let command = command.clone();
|
||||
return self.execute(&command);
|
||||
}
|
||||
@@ -8419,6 +8618,43 @@ impl Editor {
|
||||
self.active.view.mode = Mode::Command;
|
||||
}
|
||||
|
||||
fn complete_command(&mut self, forward: bool) {
|
||||
let view = &mut self.active.view;
|
||||
let command = view.command_line.trim_start();
|
||||
let name = command.strip_prefix(':').unwrap_or(command);
|
||||
if name.contains(char::is_whitespace) {
|
||||
return;
|
||||
}
|
||||
let start = view.command_line.len() - name.len();
|
||||
let cycling = view.command_completion_prefix.is_some();
|
||||
let prefix = view
|
||||
.command_completion_prefix
|
||||
.get_or_insert_with(|| name.to_owned());
|
||||
let precedes = |left: &str, right: &str| {
|
||||
if forward { left < right } else { left > right }
|
||||
};
|
||||
let mut first = None;
|
||||
let mut next = None;
|
||||
for candidate in self.features.values().flat_map(|feature| &feature.commands) {
|
||||
let candidate = candidate.as_str();
|
||||
if !candidate.starts_with(prefix.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if first.is_none_or(|first| precedes(candidate, first)) {
|
||||
first = Some(candidate);
|
||||
}
|
||||
if cycling
|
||||
&& precedes(name, candidate)
|
||||
&& next.is_none_or(|next| precedes(candidate, next))
|
||||
{
|
||||
next = Some(candidate);
|
||||
}
|
||||
}
|
||||
if let Some(candidate) = next.or(first) {
|
||||
view.command_line.replace_range(start.., candidate);
|
||||
}
|
||||
}
|
||||
|
||||
fn navigate_prompt_history(&mut self, previous: bool) {
|
||||
let history = if self.active.view.mode == Mode::Search {
|
||||
&self.search_history
|
||||
@@ -8514,6 +8750,9 @@ impl Editor {
|
||||
let count = self.count.take().unwrap_or(1);
|
||||
self.clear_sequence();
|
||||
match action {
|
||||
Action::CommandComplete | Action::CommandCompletePrevious => {
|
||||
self.complete_command(action == Action::CommandComplete);
|
||||
}
|
||||
Action::SnippetNext => {
|
||||
if self.snippet.is_some() {
|
||||
self.navigate_snippet(true);
|
||||
@@ -9474,6 +9713,20 @@ impl Editor {
|
||||
|
||||
fn execute_command(&mut self, command: &str) -> Option<Invocation> {
|
||||
let command = command.trim().strip_prefix(':').unwrap_or(command.trim());
|
||||
if let Some(command) = command.strip_prefix('!') {
|
||||
self.prepare_command();
|
||||
self.active.view.command_line.clear();
|
||||
self.finish_command_mode();
|
||||
if command.trim().is_empty() {
|
||||
self.set_message("Shell command is empty");
|
||||
return None;
|
||||
}
|
||||
return Some(Invocation {
|
||||
plugin: "core".into(),
|
||||
command: "shell".into(),
|
||||
args: command.into(),
|
||||
});
|
||||
}
|
||||
let (name, args) = command
|
||||
.split_once(char::is_whitespace)
|
||||
.unwrap_or((command, ""));
|
||||
@@ -9525,6 +9778,19 @@ impl Editor {
|
||||
}
|
||||
|
||||
fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option<Invocation> {
|
||||
if entry.action == Action::WordWrapToggle
|
||||
&& let Some(output) = &mut self.command_output
|
||||
{
|
||||
if args.is_empty() {
|
||||
output.wrapping = !output.wrapping;
|
||||
output.content.top = 0;
|
||||
output.content.left = 0;
|
||||
} else {
|
||||
self.set_message("The word_wrap.toggle command takes no arguments");
|
||||
}
|
||||
self.clear_sequence();
|
||||
return None;
|
||||
}
|
||||
if matches!(
|
||||
entry.action,
|
||||
Action::DiffComment
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod resolve;
|
||||
pub mod review;
|
||||
pub mod ruby;
|
||||
pub mod session;
|
||||
mod shell;
|
||||
mod snippet;
|
||||
pub mod syntax;
|
||||
mod terminal_colors;
|
||||
|
||||
@@ -57,9 +57,12 @@ Insert: Ctrl+n requests LSP completion at primary caret, then cycles next; Ctrl+
|
||||
Outside snippets Enter inserts newline, Tab inserts a tab or configured spaces; Esc returns to Normal
|
||||
Insert/Command/Search: Ctrl+u deletes backward to the logical line start
|
||||
Command/Search: Up/Ctrl+k older history; Down/Ctrl+j newer history or unfinished input; Enter submits
|
||||
Command: Tab/Shift+Tab completes command names and cycles forward/backward with wraparound (not arguments)
|
||||
Search: Enter accepts current/next literal match, Esc cancels (case-sensitive, wraps)
|
||||
Commands: :save [PATH], :force_save [PATH], :quit, :force_quit,
|
||||
:save_and_quit [PATH], :force_save_and_quit [PATH], :plugins, :PLUGIN [ARGS]
|
||||
Shell: :!COMMAND runs asynchronously in the project directory using $SHELL (or /bin/sh)
|
||||
Pending command/spinner in top bar; output/exit status in floating window; Enter/Esc closes
|
||||
Aliases: :w=save, :w!=force_save, :q=quit, :q!=force_quit,
|
||||
:wq=save_and_quit, :wq!=force_save_and_quit
|
||||
Save actions: core.save, core.force_save, core.save_and_quit, core.force_save_and_quit
|
||||
|
||||
@@ -439,6 +439,13 @@ fn text_fits(lines: &[String], limit: usize) -> bool {
|
||||
.is_some()
|
||||
}
|
||||
|
||||
struct ShellCommand {
|
||||
command: String,
|
||||
worker: crate::shell::Worker,
|
||||
started: Instant,
|
||||
label: String,
|
||||
}
|
||||
|
||||
/// Owns command eligibility and execution hosts together. Frontends can inspect
|
||||
/// the editor, but cannot independently mutate its plugin registry.
|
||||
pub struct EditorSession {
|
||||
@@ -468,6 +475,7 @@ pub struct EditorSession {
|
||||
picker_source: Option<PathBuf>,
|
||||
battery: Option<crate::battery::Battery>,
|
||||
battery_enabled: bool,
|
||||
shell: Vec<ShellCommand>,
|
||||
last_file_refresh: Instant,
|
||||
last_diff_refresh: Instant,
|
||||
pub(crate) diff_follow_working_copy: bool,
|
||||
@@ -595,6 +603,7 @@ impl EditorSession {
|
||||
picker_source: None,
|
||||
battery: None,
|
||||
battery_enabled: false,
|
||||
shell: Vec::new(),
|
||||
last_file_refresh: Instant::now(),
|
||||
last_diff_refresh: Instant::now(),
|
||||
diff_follow_working_copy: config.diff_follow_working_copy,
|
||||
@@ -827,6 +836,25 @@ impl EditorSession {
|
||||
|
||||
fn dispatch(&mut self, invocation: Invocation) {
|
||||
if invocation.plugin == "core" {
|
||||
if invocation.command == "shell" {
|
||||
let command = invocation.args;
|
||||
match crate::shell::Worker::start(
|
||||
command.clone(),
|
||||
self.editor.project_root().to_path_buf(),
|
||||
) {
|
||||
Ok(worker) => {
|
||||
self.shell.push(ShellCommand {
|
||||
command,
|
||||
worker,
|
||||
started: Instant::now(),
|
||||
label: String::new(),
|
||||
});
|
||||
self.poll_shell_at(Instant::now());
|
||||
}
|
||||
Err(error) => self.editor.show_command_output(command, Err(error)),
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !self.editor.document_actions_available()
|
||||
|| (invocation.command == "lsp.completion" && self.editor.mutation_blocked())
|
||||
{
|
||||
@@ -1750,6 +1778,51 @@ impl EditorSession {
|
||||
changed
|
||||
}
|
||||
|
||||
fn poll_shell_at(&mut self, now: Instant) -> bool {
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
const FRAMES: [char; 4] = ['|', '/', '-', '\\'];
|
||||
let mut changed = false;
|
||||
self.shell.retain_mut(|running| {
|
||||
if let Some(result) = running.worker.poll() {
|
||||
self.editor
|
||||
.show_command_output(std::mem::take(&mut running.command), result);
|
||||
changed = true;
|
||||
return false;
|
||||
}
|
||||
let elapsed = now.saturating_duration_since(running.started);
|
||||
if elapsed >= Duration::from_millis(200) {
|
||||
let frame = FRAMES[((elapsed.as_millis() / 100) % 4) as usize];
|
||||
if !running.label.ends_with(frame) {
|
||||
running.label.clear();
|
||||
let mut graphemes = running.command.trim().graphemes(true);
|
||||
running.label.extend(graphemes.by_ref().take(3));
|
||||
if graphemes.next().is_some() {
|
||||
running.label.push_str("...");
|
||||
}
|
||||
running.label.push(' ');
|
||||
running.label.push(frame);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
if changed {
|
||||
let mut label = String::new();
|
||||
for running in self
|
||||
.shell
|
||||
.iter()
|
||||
.filter(|running| !running.label.is_empty())
|
||||
{
|
||||
if !label.is_empty() {
|
||||
label.push_str(" ");
|
||||
}
|
||||
label.push_str(&running.label);
|
||||
}
|
||||
self.editor.set_shell_label(label);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn refresh_files_at(&mut self, now: Instant) -> bool {
|
||||
if now.saturating_duration_since(self.last_file_refresh) < Duration::from_millis(250) {
|
||||
return false;
|
||||
@@ -1777,6 +1850,7 @@ impl EditorSession {
|
||||
changed |= self.poll_gutter_at(now);
|
||||
changed |= self.synchronize_documents();
|
||||
changed |= self.poll_battery();
|
||||
changed |= self.poll_shell_at(now);
|
||||
self.refresh_checkout_diffs_at(now);
|
||||
changed |= self.poll_diff();
|
||||
changed |= self.poll_resolver();
|
||||
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
#[cfg(unix)]
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
io::{self, Read},
|
||||
os::{
|
||||
fd::OwnedFd,
|
||||
unix::{net::UnixStream, process::CommandExt},
|
||||
},
|
||||
process::{Child, Command, Stdio},
|
||||
sync::mpsc::{self, RecvTimeoutError},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::mpsc::{Receiver, Sender, TryRecvError},
|
||||
thread::JoinHandle,
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
const OUTPUT_LIMIT: usize = 4 * 1024 * 1024;
|
||||
#[cfg(unix)]
|
||||
const TRUNCATION_NOTE: &[u8] = b"\n[vamp: output truncated after 4 MiB]\n";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Output {
|
||||
pub(crate) code: Option<i32>,
|
||||
pub(crate) stdout: Vec<u8>,
|
||||
pub(crate) stderr: Vec<u8>,
|
||||
}
|
||||
|
||||
pub(crate) struct Worker {
|
||||
cancel: Sender<()>,
|
||||
results: Receiver<Result<Output, String>>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
pub(crate) fn start(command: String, root: PathBuf) -> Result<Self, String> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let shell = std::env::var_os("SHELL")
|
||||
.filter(|shell| !shell.is_empty())
|
||||
.unwrap_or_else(|| "/bin/sh".into());
|
||||
Self::start_with_shell(command, root, shell)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = (command, root);
|
||||
Err("Shell commands require Unix process-group cancellation on this platform".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn start_with_shell(command: String, root: PathBuf, shell: OsString) -> Result<Self, String> {
|
||||
let (cancel, cancelled) = mpsc::channel();
|
||||
let (send, results) = mpsc::channel();
|
||||
let thread = thread::Builder::new()
|
||||
.name("vamp-shell".into())
|
||||
.spawn(move || {
|
||||
let _ = send.send(run(command, root, shell, cancelled));
|
||||
})
|
||||
.map_err(|error| format!("Cannot start shell worker: {error}"))?;
|
||||
Ok(Self {
|
||||
cancel,
|
||||
results,
|
||||
thread: Some(thread),
|
||||
finished: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn poll(&mut self) -> Option<Result<Output, String>> {
|
||||
if self.finished {
|
||||
return None;
|
||||
}
|
||||
let result = match self.results.try_recv() {
|
||||
Ok(output) => output,
|
||||
Err(TryRecvError::Empty) => return None,
|
||||
Err(TryRecvError::Disconnected) => Err("Shell worker stopped unexpectedly".into()),
|
||||
};
|
||||
self.finished = true;
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Worker {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.cancel.send(());
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
struct Process(Child);
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Drop for Process {
|
||||
fn drop(&mut self) {
|
||||
unsafe extern "C" {
|
||||
fn kill(pid: i32, signal: i32) -> i32;
|
||||
}
|
||||
// The shell and its descendants share a private process group; killing
|
||||
// only the leader would leave children holding the output descriptors.
|
||||
unsafe { kill(-(self.0.id() as i32), 9) };
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
struct Capture {
|
||||
reader: UnixStream,
|
||||
bytes: Vec<u8>,
|
||||
truncated: bool,
|
||||
eof: bool,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Capture {
|
||||
fn new() -> Result<(Self, Stdio), String> {
|
||||
let (reader, writer) = UnixStream::pair()
|
||||
.map_err(|error| format!("Cannot create shell output stream: {error}"))?;
|
||||
reader
|
||||
.set_nonblocking(true)
|
||||
.map_err(|error| format!("Cannot configure shell output stream: {error}"))?;
|
||||
Ok((
|
||||
Self {
|
||||
reader,
|
||||
bytes: Vec::new(),
|
||||
truncated: false,
|
||||
eof: false,
|
||||
},
|
||||
Stdio::from(OwnedFd::from(writer)),
|
||||
))
|
||||
}
|
||||
|
||||
fn read(&mut self) -> io::Result<bool> {
|
||||
if self.eof {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut chunk = [0; 16 * 1024];
|
||||
match self.reader.read(&mut chunk) {
|
||||
Ok(0) => {
|
||||
self.eof = true;
|
||||
Ok(false)
|
||||
}
|
||||
Ok(count) => {
|
||||
let keep = count.min(OUTPUT_LIMIT.saturating_sub(self.bytes.len()));
|
||||
self.bytes.extend_from_slice(&chunk[..keep]);
|
||||
self.truncated |= keep != count;
|
||||
Ok(true)
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(false),
|
||||
Err(error) if error.kind() == io::ErrorKind::Interrupted => Ok(true),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(mut self) -> Vec<u8> {
|
||||
if self.truncated {
|
||||
self.bytes.extend_from_slice(TRUNCATION_NOTE);
|
||||
}
|
||||
self.bytes
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run(
|
||||
command: String,
|
||||
root: PathBuf,
|
||||
shell: OsString,
|
||||
cancelled: Receiver<()>,
|
||||
) -> Result<Output, String> {
|
||||
if cancelled.try_recv().is_ok() {
|
||||
return Err("Shell command cancelled".into());
|
||||
}
|
||||
let (mut stdout, stdout_writer) = Capture::new()?;
|
||||
let (mut stderr, stderr_writer) = Capture::new()?;
|
||||
let child = Command::new(shell)
|
||||
.arg("-c")
|
||||
.arg(command)
|
||||
.current_dir(root)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(stdout_writer)
|
||||
.stderr(stderr_writer)
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.map_err(|error| format!("Cannot start shell command: {error}"))?;
|
||||
let mut process = Some(Process(child));
|
||||
let mut status = None;
|
||||
loop {
|
||||
if cancelled.try_recv().is_ok() {
|
||||
return Err("Shell command cancelled".into());
|
||||
}
|
||||
// Read each stream once per turn so a busy writer cannot starve the
|
||||
// other stream or cancellation. Overflow is still drained, not retained.
|
||||
let out = stdout
|
||||
.read()
|
||||
.map_err(|error| format!("Cannot read shell stdout: {error}"))?;
|
||||
let err = stderr
|
||||
.read()
|
||||
.map_err(|error| format!("Cannot read shell stderr: {error}"))?;
|
||||
if let Some(child) = process.as_mut() {
|
||||
status = child
|
||||
.0
|
||||
.try_wait()
|
||||
.map_err(|error| format!("Cannot wait for shell command: {error}"))?;
|
||||
if status.is_some() {
|
||||
// Background jobs must not outlive the command. Killing the
|
||||
// remaining group also closes inherited output descriptors.
|
||||
drop(process.take());
|
||||
}
|
||||
}
|
||||
if let Some(status) = status
|
||||
&& stdout.eof
|
||||
&& stderr.eof
|
||||
{
|
||||
return Ok(Output {
|
||||
code: status.code(),
|
||||
stdout: stdout.finish(),
|
||||
stderr: stderr.finish(),
|
||||
});
|
||||
}
|
||||
if !out && !err {
|
||||
match cancelled.recv_timeout(Duration::from_millis(10)) {
|
||||
Ok(()) | Err(RecvTimeoutError::Disconnected) => {
|
||||
return Err("Shell command cancelled".into());
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[path = "../tests/unit/shell.rs"]
|
||||
mod tests;
|
||||
+83
-10
@@ -1142,7 +1142,10 @@ impl BufferViewports {
|
||||
let Some(kind) = wheel_kind(&mouse) else {
|
||||
return false;
|
||||
};
|
||||
if editor.palette().is_some() || editor.startup_diagnostic().is_some() {
|
||||
if editor.palette().is_some()
|
||||
|| editor.startup_diagnostic().is_some()
|
||||
|| editor.command_output().is_some()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let Some((buffer_id, viewport)) = self.document.geometry else {
|
||||
@@ -1781,6 +1784,7 @@ fn chrome_items<'a>(
|
||||
String::new()
|
||||
}),
|
||||
"battery" if editor.label(name).is_empty() => Cow::Borrowed(editor.battery_label()),
|
||||
"shell" if editor.label(name).is_empty() => Cow::Borrowed(editor.shell_label()),
|
||||
_ => Cow::Borrowed(editor.label(name)),
|
||||
};
|
||||
// Empty values remain hidden even when a title or decoration is configured.
|
||||
@@ -1829,7 +1833,7 @@ fn chrome_items<'a>(
|
||||
}
|
||||
|
||||
fn chrome_group_width(items: &[ChromeItem<'_>]) -> usize {
|
||||
items.iter().map(|item| item.width).sum()
|
||||
items.iter().map(|item| item.width).sum::<usize>() + items.len().saturating_sub(1)
|
||||
}
|
||||
|
||||
fn chrome_fits([left, center, right]: [usize; 3], width: usize) -> bool {
|
||||
@@ -1900,13 +1904,15 @@ fn draw_chrome(frame: &mut Frame, mut groups: [Vec<ChromeItem<'_>>; 3], area: Re
|
||||
}
|
||||
break;
|
||||
};
|
||||
widths[group] -= groups[group].remove(index).width;
|
||||
groups[group].remove(index);
|
||||
widths[group] = chrome_group_width(&groups[group]);
|
||||
}
|
||||
let widths = widths.map(|cells| cells.min(width));
|
||||
let starts = [0, (width - widths[1]) / 2, width - widths[2]];
|
||||
for (items, start) in groups.iter().zip(starts) {
|
||||
let mut x = start;
|
||||
for item in items {
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
x += usize::from(index > 0);
|
||||
if x >= width {
|
||||
break;
|
||||
}
|
||||
@@ -2839,6 +2845,7 @@ fn draw_overlays(
|
||||
|| editor.palette().is_some()
|
||||
|| editor.hover().is_some()
|
||||
|| editor.startup_diagnostic().is_some()
|
||||
|| editor.command_output().is_some()
|
||||
|| editor
|
||||
.file_picker()
|
||||
.is_some_and(|picker| picker.presentation == FilePresentation::Floating);
|
||||
@@ -2852,6 +2859,7 @@ fn draw_overlays(
|
||||
}
|
||||
}
|
||||
if !matches!(editor.mode(), Mode::Command | Mode::Search)
|
||||
&& editor.command_output().is_none()
|
||||
&& !editor.file_picker().is_some_and(|picker| picker.focused)
|
||||
&& let Some((_, position)) = caret
|
||||
&& !panels
|
||||
@@ -2899,6 +2907,9 @@ fn draw_overlays(
|
||||
if let Some(diagnostic) = editor.startup_diagnostic() {
|
||||
draw_startup_diagnostic(frame, diagnostic, area);
|
||||
}
|
||||
if let Some(output) = editor.command_output() {
|
||||
draw_diagnostic_panel(frame, &output.content, area, &output.title, Some(output));
|
||||
}
|
||||
}
|
||||
|
||||
struct DiffGeometry {
|
||||
@@ -4622,6 +4633,17 @@ fn floating_panel(area: Rect) -> Rect {
|
||||
}
|
||||
|
||||
fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, area: Rect) {
|
||||
draw_diagnostic_panel(frame, diagnostic, area, "Configuration error", None);
|
||||
}
|
||||
|
||||
fn draw_diagnostic_panel(
|
||||
frame: &mut Frame,
|
||||
diagnostic: &StartupDiagnostic,
|
||||
area: Rect,
|
||||
title: &str,
|
||||
output: Option<&crate::editor::ShellOutput>,
|
||||
) {
|
||||
let startup = output.is_none();
|
||||
let panel = floating_panel(area);
|
||||
frame.render_widget(Clear, panel);
|
||||
let bordered = panel.width >= 4 && panel.height >= 4;
|
||||
@@ -4631,8 +4653,8 @@ fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, ar
|
||||
} else {
|
||||
Borders::NONE
|
||||
})
|
||||
.border_style(Style::default().fg(Color::Red))
|
||||
.title(" Configuration error ");
|
||||
.border_style(Style::default().fg(if startup { Color::Red } else { Color::DarkGray }))
|
||||
.title(format!(" {} ", display_text(title)));
|
||||
let content = if bordered { block.inner(panel) } else { panel };
|
||||
if bordered {
|
||||
frame.render_widget(block, panel);
|
||||
@@ -4642,7 +4664,40 @@ fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, ar
|
||||
Constraint::Length(u16::from(content.height >= 3)),
|
||||
])
|
||||
.areas(content);
|
||||
let lines: Vec<Line> = diagnostic
|
||||
let lines: Vec<Line> = if let Some(output) = output.filter(|output| output.wrapping) {
|
||||
let width = usize::from(text_area.width).max(1);
|
||||
let mut cached = output.wrapped_lines.borrow_mut();
|
||||
if cached.0 != width {
|
||||
let mut cache = crate::wrap::WrapCache::default();
|
||||
cache.prepare((0, 0), width);
|
||||
cached.1.clear();
|
||||
for (row, line) in diagnostic.lines.iter().enumerate() {
|
||||
let segments = cache.segments(row, line);
|
||||
for (index, segment) in segments.iter().enumerate() {
|
||||
// Output has no insertion caret: omit its extra empty continuation.
|
||||
if index == 0 || segment.start != segment.end {
|
||||
cached.1.push((row, *segment));
|
||||
}
|
||||
}
|
||||
}
|
||||
cached.0 = width;
|
||||
}
|
||||
output.wrapped_rows.set(cached.1.len());
|
||||
cached
|
||||
.1
|
||||
.iter()
|
||||
.skip(diagnostic.top.min(cached.1.len().saturating_sub(1)))
|
||||
.take(usize::from(text_area.height))
|
||||
.map(|(row, segment)| {
|
||||
Line::from(visible_line(
|
||||
&diagnostic.lines[*row],
|
||||
segment.start,
|
||||
(segment.end - segment.start).min(width),
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
diagnostic
|
||||
.lines
|
||||
.iter()
|
||||
.skip(diagnostic.top)
|
||||
@@ -4655,14 +4710,19 @@ fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, ar
|
||||
usize::from(text_area.width),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
.collect()
|
||||
};
|
||||
frame.render_widget(Paragraph::new(lines), text_area);
|
||||
frame.render_widget(
|
||||
Paragraph::new("Arrows scroll · Home reset · Enter/Esc continue")
|
||||
Paragraph::new(if startup {
|
||||
"Arrows scroll · Home reset · Enter/Esc continue"
|
||||
} else {
|
||||
"Arrows scroll · Home reset · Enter/Esc close"
|
||||
})
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
help_area,
|
||||
);
|
||||
if text_area.width > 0 && text_area.height > 0 {
|
||||
if startup && text_area.width > 0 && text_area.height > 0 {
|
||||
frame.set_cursor_position((text_area.x, text_area.y));
|
||||
}
|
||||
}
|
||||
@@ -5546,6 +5606,19 @@ impl WheelPanels {
|
||||
let Some(kind) = wheel_kind(&mouse) else {
|
||||
return false;
|
||||
};
|
||||
if session.editor().command_output().is_some() {
|
||||
let input = match kind {
|
||||
MouseEventKind::ScrollUp => Input::Up,
|
||||
MouseEventKind::ScrollDown => Input::Down,
|
||||
MouseEventKind::ScrollLeft => Input::Left,
|
||||
MouseEventKind::ScrollRight => Input::Right,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
for _ in 0..3 {
|
||||
session.handle(input.clone());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let horizontal = matches!(
|
||||
kind,
|
||||
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight
|
||||
|
||||
@@ -1067,6 +1067,167 @@ fn focused_text_fields_keep_printable_normal_bindings_as_text() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_completion_cycles_core_alias_and_live_plugin_names_without_executing() {
|
||||
let mut editor = editor();
|
||||
editor.register(plugin("fixture", &["save_note"])).unwrap();
|
||||
let document = editor.context().text;
|
||||
type_text(&mut editor, ":sav");
|
||||
for expected in ["save", "save_and_quit", "save_note", "save"] {
|
||||
assert_eq!(editor.handle(Input::Char('\t')), None);
|
||||
assert_eq!(editor.command_line(), expected);
|
||||
assert_eq!(editor.mode(), Mode::Command);
|
||||
assert_eq!(editor.context().text, document);
|
||||
assert!(!editor.should_quit());
|
||||
}
|
||||
for expected in ["save_note", "save_and_quit", "save", "save_note"] {
|
||||
assert_eq!(editor.handle(Input::BackTab), None);
|
||||
assert_eq!(editor.command_line(), expected);
|
||||
}
|
||||
let invocation = editor.handle(Input::Enter).unwrap();
|
||||
assert_eq!(invocation.plugin, "fixture");
|
||||
assert_eq!(invocation.command, "save_note");
|
||||
assert_eq!(invocation.args, "");
|
||||
|
||||
type_text(&mut editor, ":q");
|
||||
for expected in ["q", "q!", "quit", "q"] {
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert_eq!(editor.command_line(), expected);
|
||||
assert!(!editor.should_quit());
|
||||
}
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, ":sav");
|
||||
editor.handle(Input::Char('\t'));
|
||||
editor.disable_plugin("fixture", "test");
|
||||
for expected in ["save_and_quit", "save"] {
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert_eq!(editor.command_line(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_completion_reverse_starts_at_last_match_and_retains_prefix_on_direction_changes() {
|
||||
let mut editor = editor();
|
||||
type_text(&mut editor, ":tab.");
|
||||
for (key, expected) in [
|
||||
(Input::BackTab, "tab.previous"),
|
||||
(Input::BackTab, "tab.next"),
|
||||
(Input::Char('\t'), "tab.previous"),
|
||||
(Input::Char('\t'), "tab.close"),
|
||||
(Input::BackTab, "tab.previous"),
|
||||
] {
|
||||
editor.handle(key);
|
||||
assert_eq!(editor.command_line(), expected);
|
||||
assert_eq!(editor.tab_count(), 1);
|
||||
}
|
||||
editor.handle(Input::Escape);
|
||||
for draft in ["plugins", "unknown", "界", "edit file name"] {
|
||||
type_text(&mut editor, ":");
|
||||
type_text(&mut editor, draft);
|
||||
for _ in 0..2 {
|
||||
editor.handle(Input::BackTab);
|
||||
assert_eq!(editor.command_line(), draft);
|
||||
}
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
editor.bind("command", "shift+tab", None).unwrap();
|
||||
editor
|
||||
.bind(
|
||||
"command",
|
||||
"ctrl+b",
|
||||
Some("core.command_complete_previous".into()),
|
||||
)
|
||||
.unwrap();
|
||||
type_text(&mut editor, ":tab.");
|
||||
editor.handle(Input::BackTab);
|
||||
assert_eq!(editor.command_line(), "tab.");
|
||||
editor.handle(Input::Ctrl('b'));
|
||||
assert_eq!(editor.command_line(), "tab.previous");
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert_eq!(editor.command_line(), "tab.close");
|
||||
editor.handle(Input::Ctrl('b'));
|
||||
assert_eq!(editor.command_line(), "tab.previous");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_completion_restarts_after_edits_history_and_cancellation_and_ignores_arguments() {
|
||||
let mut editor = editor();
|
||||
type_text(&mut editor, ":plugins\n:sav");
|
||||
editor.handle(Input::Char('\t'));
|
||||
type_text(&mut editor, "_");
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert_eq!(editor.command_line(), "save_and_quit");
|
||||
editor.handle(Input::Ctrl('u'));
|
||||
type_text(&mut editor, "q!");
|
||||
editor.handle(Input::Backspace);
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert_eq!(editor.command_line(), "q");
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert_eq!(editor.command_line(), "q!");
|
||||
editor.handle(Input::Up);
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert_eq!(editor.command_line(), "plugins");
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert_eq!(editor.command_line(), "q!");
|
||||
editor.handle(Input::Escape);
|
||||
|
||||
for (draft, expected) in [
|
||||
("", "bdelete"),
|
||||
(" :sav", " :save"),
|
||||
("no_such_command", "no_such_command"),
|
||||
("界e\u{301}", "界e\u{301}"),
|
||||
("edit ", "edit "),
|
||||
("save 界 file.txt", "save 界 file.txt"),
|
||||
] {
|
||||
type_text(&mut editor, ":");
|
||||
type_text(&mut editor, draft);
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert_eq!(editor.command_line(), expected);
|
||||
editor.handle(Input::Escape);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_completion_respects_bindings_and_preserves_visual_diff_and_search_input() {
|
||||
let directory = Directory::new();
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.bind("command", "ctrl+n", Some("core.command_complete".into()))
|
||||
.unwrap();
|
||||
editor.bind("command", "tab", None).unwrap();
|
||||
historical_diff(
|
||||
&mut editor,
|
||||
&directory.0,
|
||||
vec!["needle before".into()],
|
||||
vec!["needle after".into()],
|
||||
);
|
||||
editor.handle(Input::Char('V'));
|
||||
let selections = editor.selections();
|
||||
let cursor = editor.cursor();
|
||||
type_text(&mut editor, ":diff.com");
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert_eq!(editor.command_line(), "diff.com");
|
||||
editor.handle(Input::Ctrl('n'));
|
||||
assert_eq!(editor.command_line(), "diff.comment");
|
||||
assert_eq!(editor.selections(), selections);
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.mode(), Mode::Visual);
|
||||
assert_eq!(editor.selections(), selections);
|
||||
editor.handle(Input::Escape);
|
||||
type_text(&mut editor, "/sav");
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert_eq!(editor.command_line(), "sav\t");
|
||||
editor.handle(Input::Escape);
|
||||
editor
|
||||
.bind("command", "tab", Some("core.up".into()))
|
||||
.unwrap();
|
||||
type_text(&mut editor, ":plugins\n:sav");
|
||||
editor.handle(Input::Char('\t'));
|
||||
assert_eq!(editor.command_line(), "plugins");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_history_browses_without_executing_and_restores_unicode_drafts() {
|
||||
for prefix in [':', '/'] {
|
||||
|
||||
@@ -931,7 +931,6 @@ 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}");
|
||||
|
||||
|
||||
@@ -4,6 +4,132 @@ use std::{
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn concurrent_shell_commands_share_status_and_queue_every_completed_output() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.file("buffer.txt", "source");
|
||||
let mut session = fixture.open("");
|
||||
session.editor.set_project_root(fixture.0.clone());
|
||||
session.execute("!printf FIRST; while [ ! -f first ]; do sleep 0.01; done");
|
||||
session.execute("!echo SECOND; while [ ! -f second ]; do sleep 0.01; done; exit 9");
|
||||
assert_eq!(session.shell.len(), 2);
|
||||
let fixed = Instant::now();
|
||||
for running in &mut session.shell {
|
||||
running.started = fixed;
|
||||
}
|
||||
session.poll_shell_at(fixed + Duration::from_millis(199));
|
||||
assert_eq!(session.editor.shell_label(), "");
|
||||
session.poll_shell_at(fixed + Duration::from_millis(200));
|
||||
assert_eq!(session.editor.shell_label(), "pri... - ech... -");
|
||||
fixture.file("second", "");
|
||||
drain_until(&mut session, |session| session.shell.len() == 1);
|
||||
assert_eq!(
|
||||
session.editor.command_output().unwrap().content.lines[0],
|
||||
"SECOND"
|
||||
);
|
||||
assert!(
|
||||
session
|
||||
.editor
|
||||
.command_output()
|
||||
.unwrap()
|
||||
.title
|
||||
.contains("exit 9")
|
||||
);
|
||||
assert!(session.editor.shell_label().starts_with("pri... "));
|
||||
assert!(!session.editor.shell_label().contains("ech..."));
|
||||
fixture.file("first", "");
|
||||
drain_until(&mut session, |session| session.shell.is_empty());
|
||||
assert_eq!(session.editor.shell_label(), "");
|
||||
assert_eq!(
|
||||
session.editor.command_output().unwrap().content.lines[0],
|
||||
"SECOND"
|
||||
);
|
||||
session.handle(Input::Enter);
|
||||
assert_eq!(
|
||||
session.editor.command_output().unwrap().content.lines,
|
||||
["FIRST"]
|
||||
);
|
||||
assert!(
|
||||
session
|
||||
.editor
|
||||
.command_output()
|
||||
.unwrap()
|
||||
.title
|
||||
.contains("exit 0")
|
||||
);
|
||||
session.handle(Input::Escape);
|
||||
assert!(session.editor.command_output().is_none());
|
||||
assert_eq!(session.editor.lines(), ["source"]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn shell_commands_leave_editing_live_and_present_both_streams_and_exit_status() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.file("buffer.txt", "source");
|
||||
let mut session = fixture.open("");
|
||||
session.editor.set_project_root(fixture.0.clone());
|
||||
for ch in
|
||||
":!while [ ! -f release ]; do sleep 0.01; done; pwd; printf 'out'; printf 'err' >&2; exit 7"
|
||||
.chars()
|
||||
{
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
session.handle(Input::Enter);
|
||||
let fixed = Instant::now();
|
||||
session.shell[0].started = fixed;
|
||||
assert!(!session.poll_shell_at(fixed + Duration::from_millis(199)));
|
||||
assert_eq!(session.editor.shell_label(), "");
|
||||
assert!(session.poll_shell_at(fixed + Duration::from_millis(200)));
|
||||
assert_eq!(session.editor.shell_label(), "whi... -");
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::Char('X'));
|
||||
assert_eq!(session.editor.lines(), ["Xsource"]);
|
||||
fixture.file("release", "");
|
||||
drain_until(&mut session, |session| {
|
||||
session.editor.command_output().is_some()
|
||||
});
|
||||
let output = session.editor.command_output().unwrap();
|
||||
assert!(output.title.contains("exit 7"));
|
||||
let text = output.content.lines.join("\n");
|
||||
assert!(text.contains(fixture.0.to_str().unwrap()));
|
||||
assert!(text.ends_with("out\n[stderr]\nerr"), "{text}");
|
||||
assert_eq!(session.editor.shell_label(), "");
|
||||
assert_eq!(session.editor.mode(), Mode::Insert);
|
||||
session.handle(Input::Char('Z'));
|
||||
assert_eq!(session.editor.lines(), ["Xsource"]);
|
||||
session.handle(Input::Escape);
|
||||
session.handle(Input::Char('Y'));
|
||||
assert_eq!(session.editor.lines(), ["XYsource"]);
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
|
||||
"source"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn shell_commands_reject_empty_input_and_show_empty_success_without_editing() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.file("buffer.txt", "source");
|
||||
let mut session = fixture.open("");
|
||||
session.execute(":!");
|
||||
assert_eq!(session.editor.message(), "Shell command is empty");
|
||||
assert!(session.shell.is_empty());
|
||||
session.execute(":!true");
|
||||
drain_until(&mut session, |session| {
|
||||
session.editor.command_output().is_some()
|
||||
});
|
||||
let output = session.editor.command_output().unwrap();
|
||||
assert_eq!(output.content.lines, ["(no output)"]);
|
||||
assert!(output.title.contains("exit 0"));
|
||||
session.handle(Input::Enter);
|
||||
assert!(session.editor.command_output().is_none());
|
||||
assert_eq!(session.editor.lines(), ["source"]);
|
||||
assert!(!session.editor.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_repository_text_skips_snapshots_and_recovers_after_shrinking() {
|
||||
let fixture = Fixture::new();
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
use super::*;
|
||||
use std::{
|
||||
fs,
|
||||
path::Path,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
static NEXT_ROOT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
loop {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"vamp-shell-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_ROOT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
match fs::create_dir(&root) {
|
||||
Ok(()) => return Self(fs::canonicalize(root).unwrap()),
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => panic!("Cannot create shell fixture: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn start(command: &str, root: &Path) -> Worker {
|
||||
Worker::start_with_shell(command.into(), root.into(), "/bin/sh".into()).unwrap()
|
||||
}
|
||||
|
||||
fn wait_for<T>(mut ready: impl FnMut() -> Option<T>) -> T {
|
||||
// File gates determine ordering; this deadline only bounds a stuck test.
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
if let Some(value) = ready() {
|
||||
return value;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "Shell fixture did not progress");
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
fn process_running(pid: &str) -> bool {
|
||||
let output = Command::new("/bin/ps")
|
||||
.args(["-o", "stat=", "-p", pid])
|
||||
.output()
|
||||
.unwrap();
|
||||
let state = String::from_utf8(output.stdout).unwrap();
|
||||
let state = state.trim();
|
||||
// An orphan may await the system reaper, but a zombie cannot keep running
|
||||
// or retain the inherited output descriptors.
|
||||
!state.is_empty() && !state.starts_with('Z')
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_is_pending_until_gate_and_preserves_cwd_streams_status_and_null_stdin() {
|
||||
let fixture = Fixture::new();
|
||||
let mut worker = start(
|
||||
"printf '%s\\n' \"$PWD\"; : > ready; \
|
||||
while [ ! -e release ]; do :; done; \
|
||||
if read value; then exit 99; fi; \
|
||||
printf 'out\\000bytes'; printf 'err\\000bytes' >&2; exit 7",
|
||||
&fixture.0,
|
||||
);
|
||||
wait_for(|| fixture.0.join("ready").exists().then_some(()));
|
||||
assert!(worker.poll().is_none());
|
||||
fs::write(fixture.0.join("release"), b"").unwrap();
|
||||
let output = wait_for(|| worker.poll()).unwrap();
|
||||
let mut expected = fixture.0.as_os_str().as_encoded_bytes().to_vec();
|
||||
expected.extend_from_slice(b"\nout\0bytes");
|
||||
assert_eq!(output.stdout, expected);
|
||||
assert_eq!(output.stderr, b"err\0bytes");
|
||||
assert_eq!(output.code, Some(7));
|
||||
assert!(worker.poll().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_reports_spawn_failure() {
|
||||
let fixture = Fixture::new();
|
||||
let mut worker = start("exit 0", &fixture.0.join("missing"));
|
||||
let error = wait_for(|| worker.poll()).unwrap_err();
|
||||
assert!(error.starts_with("Cannot start shell command:"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnected_shell_worker_reports_one_failure() {
|
||||
let (cancel, _cancelled) = mpsc::channel();
|
||||
let (send, results) = mpsc::channel();
|
||||
drop(send);
|
||||
let mut worker = Worker {
|
||||
cancel,
|
||||
results,
|
||||
thread: None,
|
||||
finished: false,
|
||||
};
|
||||
assert_eq!(
|
||||
worker.poll().unwrap().unwrap_err(),
|
||||
"Shell worker stopped unexpectedly"
|
||||
);
|
||||
assert!(worker.poll().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_drains_both_streams_concurrently_with_explicit_bounded_capture() {
|
||||
let fixture = Fixture::new();
|
||||
let mut worker = start(
|
||||
"(dd if=/dev/zero bs=1024 count=4097 2>/dev/null) & \
|
||||
(dd if=/dev/zero bs=1024 count=4097 2>/dev/null) >&2 & wait",
|
||||
&fixture.0,
|
||||
);
|
||||
let output = wait_for(|| worker.poll()).unwrap();
|
||||
assert_eq!(output.code, Some(0));
|
||||
for bytes in [output.stdout, output.stderr] {
|
||||
assert_eq!(bytes.len(), OUTPUT_LIMIT + TRUNCATION_NOTE.len());
|
||||
assert!(bytes[..OUTPUT_LIMIT].iter().all(|byte| *byte == 0));
|
||||
assert_eq!(&bytes[OUTPUT_LIMIT..], TRUNCATION_NOTE);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_shell_kills_and_reaps_group_with_pipe_inheriting_descendant() {
|
||||
let fixture = Fixture::new();
|
||||
let worker = start(
|
||||
"/bin/sh -c 'printf \"%s\" \"$$\" > descendant; : > ready; \
|
||||
while [ ! -e release ]; do :; done' & \
|
||||
printf '%s' \"$$\" > leader; wait",
|
||||
&fixture.0,
|
||||
);
|
||||
wait_for(|| fixture.0.join("ready").exists().then_some(()));
|
||||
let descendant = fs::read_to_string(fixture.0.join("descendant")).unwrap();
|
||||
let leader = wait_for(|| {
|
||||
fs::read_to_string(fixture.0.join("leader"))
|
||||
.ok()
|
||||
.filter(|pid| !pid.is_empty())
|
||||
});
|
||||
let (done, dropped) = mpsc::channel();
|
||||
let dropper = thread::spawn(move || {
|
||||
drop(worker);
|
||||
done.send(()).unwrap();
|
||||
});
|
||||
dropped.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
dropper.join().unwrap();
|
||||
wait_for(|| (!process_running(&descendant)).then_some(()));
|
||||
let leader_status = Command::new("/bin/ps")
|
||||
.args(["-o", "stat=", "-p", &leader])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
leader_status.stdout.is_empty(),
|
||||
"Shell leader was not reaped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_shell_does_not_leave_background_jobs_holding_output_open() {
|
||||
let fixture = Fixture::new();
|
||||
let mut worker = start(
|
||||
"/bin/sh -c 'printf \"%s\" \"$$\" > descendant; : > ready; \
|
||||
while [ ! -e release ]; do :; done' & \
|
||||
while [ ! -e ready ]; do :; done; printf done",
|
||||
&fixture.0,
|
||||
);
|
||||
let output = wait_for(|| worker.poll()).unwrap();
|
||||
assert_eq!(output.code, Some(0));
|
||||
assert_eq!(output.stdout, b"done");
|
||||
let descendant = fs::read_to_string(fixture.0.join("descendant")).unwrap();
|
||||
wait_for(|| (!process_running(&descendant)).then_some(()));
|
||||
}
|
||||
+258
-35
@@ -4933,41 +4933,69 @@ fn native_lsp_names_are_borrowed_in_either_bar_and_ignore_configured_labels() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_position_has_only_a_leading_margin_and_lsp_has_none() {
|
||||
fn chrome_separates_visible_segments_once_in_every_anchor() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor.set_lsp_names(vec!["server".into()]);
|
||||
for anchor in 0..3 {
|
||||
configure_chrome(&mut editor, |top, bottom| {
|
||||
top.left.clear();
|
||||
top.center.clear();
|
||||
top.right = vec!["position".into()];
|
||||
bottom.left.clear();
|
||||
bottom.center.clear();
|
||||
bottom.right = vec!["lsp".into(), "position".into()];
|
||||
for line in [top, bottom] {
|
||||
line.left.clear();
|
||||
line.center.clear();
|
||||
line.right.clear();
|
||||
let names = vec![
|
||||
"before".into(),
|
||||
"lsp".into(),
|
||||
"after".into(),
|
||||
"position".into(),
|
||||
];
|
||||
match anchor {
|
||||
0 => line.left = names,
|
||||
1 => line.center = names,
|
||||
_ => line.right = names,
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 2)).unwrap();
|
||||
editor
|
||||
.set_label("before".into(), "Markdown".into())
|
||||
.unwrap();
|
||||
editor.set_label("after".into(), "tail".into()).unwrap();
|
||||
for servers in [vec![], vec!["server".into()]] {
|
||||
editor.set_lsp_names(servers);
|
||||
let expected = if editor.lsp_label().is_empty() {
|
||||
"Markdown tail ░▓ 1:1 "
|
||||
} else {
|
||||
"Markdown server tail ░▓ 1:1 "
|
||||
};
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(50, 2)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
for (line, y) in [
|
||||
(editor.top_bar_config(), 0),
|
||||
(editor.bottom_bar_config(), 1),
|
||||
] {
|
||||
let groups = chrome_groups(&editor, line, &SyntaxCache::new());
|
||||
let start = 40 - chrome_group_width(&groups[2]) as u16;
|
||||
if y == 1 {
|
||||
let gap = start + groups[2][0].width as u16;
|
||||
draw_chrome(frame, groups, Rect::new(0, y, 40, 1));
|
||||
assert_eq!(frame.buffer_mut()[(start, y)].bg, Color::Cyan);
|
||||
assert_eq!(frame.buffer_mut()[(gap, y)].symbol(), " ");
|
||||
assert_eq!(frame.buffer_mut()[(gap, y)].bg, Color::Reset);
|
||||
assert_eq!(frame.buffer_mut()[(gap + 1, y)].symbol(), "░");
|
||||
} else {
|
||||
draw_chrome(frame, groups, Rect::new(0, y, 40, 1));
|
||||
assert_eq!(frame.buffer_mut()[(start, y)].symbol(), "░");
|
||||
}
|
||||
assert_eq!(frame.buffer_mut()[(39, y)].bg, Color::Cyan);
|
||||
draw_chrome(
|
||||
frame,
|
||||
chrome_groups(&editor, line, &SyntaxCache::new()),
|
||||
Rect::new(0, y, 50, 1),
|
||||
);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let start = match anchor {
|
||||
0 => 0,
|
||||
1 => (50 - expected.width()) / 2,
|
||||
_ => 50 - expected.width(),
|
||||
};
|
||||
for y in 0..2 {
|
||||
assert_eq!(
|
||||
pane_screen(
|
||||
&terminal,
|
||||
Rect::new(start as u16, y, expected.width() as u16, 1)
|
||||
),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5855,8 +5883,8 @@ fn chrome_anchors_center_on_the_full_row_and_preserve_right_order() {
|
||||
assert_eq!(buffer[(25, y)].symbol(), "I");
|
||||
assert_eq!(buffer[(26, y)].symbol(), "D");
|
||||
}
|
||||
assert!(pane_screen(&terminal, Rect::new(0, 0, 51, 1)).ends_with("ABC"));
|
||||
assert!(pane_screen(&terminal, Rect::new(3, 2, 45, 1)).ends_with("ABC"));
|
||||
assert!(pane_screen(&terminal, Rect::new(0, 0, 51, 1)).ends_with("A BC"));
|
||||
assert!(pane_screen(&terminal, Rect::new(3, 2, 45, 1)).ends_with("A BC"));
|
||||
|
||||
editor.set_label("left".into(), "L".repeat(30)).unwrap();
|
||||
terminal
|
||||
@@ -5876,7 +5904,7 @@ fn chrome_anchors_center_on_the_full_row_and_preserve_right_order() {
|
||||
for (y, x, width) in [(0, 0, 51), (2, 3, 45)] {
|
||||
let row = pane_screen(&terminal, Rect::new(x, y, width, 1));
|
||||
assert!(!row.contains("MID"), "{row}");
|
||||
assert!(row.ends_with("ABC"), "{row}");
|
||||
assert!(row.ends_with("A BC"), "{row}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6024,11 +6052,11 @@ fn chrome_uses_cell_widths_for_independent_edges_and_internal_padding() {
|
||||
let buffer = terminal.backend().buffer();
|
||||
for (x, text) in [
|
||||
(2, "1"),
|
||||
(5, "界"),
|
||||
(7, "▌"),
|
||||
(10, "2"),
|
||||
(13, "▐"),
|
||||
(14, "界"),
|
||||
(6, "界"),
|
||||
(8, "▌"),
|
||||
(11, "2"),
|
||||
(14, "▐"),
|
||||
(15, "界"),
|
||||
(29, "界"),
|
||||
(31, "("),
|
||||
(34, "e\u{301}"),
|
||||
@@ -6051,9 +6079,9 @@ fn chrome_uses_cell_widths_for_independent_edges_and_internal_padding() {
|
||||
assert_eq!(buffer[(x, 2)].bg, Color::Cyan);
|
||||
}
|
||||
assert_eq!(buffer[(2, 0)].bg, Color::Reset);
|
||||
assert_eq!(buffer[(10, 0)].bg, Color::Cyan);
|
||||
assert_eq!(buffer[(11, 0)].bg, Color::Cyan);
|
||||
assert_eq!(buffer[(5, 0)].bg, Color::Reset);
|
||||
for x in [8, 9, 11, 12, 32, 33, 35, 36] {
|
||||
for x in [9, 10, 12, 13, 32, 33, 35, 36] {
|
||||
assert_eq!(buffer[(x, 0)].symbol(), " ");
|
||||
assert_eq!(buffer[(x, 0)].bg, Color::Cyan);
|
||||
}
|
||||
@@ -6071,8 +6099,8 @@ fn chrome_uses_cell_widths_for_independent_edges_and_internal_padding() {
|
||||
assert_eq!(buffer[(0, 0)].symbol(), "<");
|
||||
assert_eq!(buffer[(3, 0)].symbol(), "1");
|
||||
assert_eq!(buffer[(3, 0)].bg, Color::Cyan);
|
||||
assert_eq!(buffer[(11, 0)].symbol(), "2");
|
||||
assert_eq!(buffer[(11, 0)].bg, Color::Reset);
|
||||
assert_eq!(buffer[(12, 0)].symbol(), "2");
|
||||
assert_eq!(buffer[(12, 0)].bg, Color::Reset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -15354,3 +15382,198 @@ fn draw(
|
||||
frame, editor, workspace, workspace, top, left, syntax, document, panels,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_output_popup_routes_wheels_and_hides_document_cursor() {
|
||||
let mut session = EditorSession::open(None, vec![], None).unwrap();
|
||||
session.editor_mut().set_message("");
|
||||
let source = session.editor().lines().to_vec();
|
||||
let cursor = session.editor().cursor();
|
||||
session.editor_mut().show_command_output(
|
||||
"printf output".into(),
|
||||
Err((0..40)
|
||||
.map(|row| format!("OUTPUT {row} {}", "wide ".repeat(30)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")),
|
||||
);
|
||||
let area = Rect::new(3, 2, 80, 16);
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(90, 22),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("printf output"));
|
||||
assert!(text.contains("OUTPUT 0"));
|
||||
assert!(text.contains("Enter/Esc close"));
|
||||
assert!(!terminal.backend().cursor_visible());
|
||||
let offsets = views.offsets.clone();
|
||||
assert!(views.scroll(
|
||||
&mut session,
|
||||
document_wheel(MouseEventKind::ScrollDown, area.x, area.y)
|
||||
));
|
||||
assert_eq!(session.editor().command_output().unwrap().content.top, 3);
|
||||
assert!(views.scroll(
|
||||
&mut session,
|
||||
document_wheel(MouseEventKind::ScrollRight, area.x, area.y)
|
||||
));
|
||||
assert_eq!(session.editor().command_output().unwrap().content.left, 3);
|
||||
assert_eq!(views.offsets, offsets);
|
||||
assert_eq!(session.editor().lines(), source.as_slice());
|
||||
assert_eq!(session.editor().cursor(), cursor);
|
||||
session.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
assert!(session.editor().command_output().is_none());
|
||||
assert!(terminal.backend().cursor_visible());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_output_panel_clips_sanitized_content_in_offset_and_tiny_areas() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor.show_command_output(
|
||||
"shell title".into(),
|
||||
Err("SKIPPED\nabcdVISIBLE\u{1b} tail".into()),
|
||||
);
|
||||
editor.handle(Input::Down);
|
||||
for _ in 0..4 {
|
||||
editor.handle(Input::Right);
|
||||
}
|
||||
let diagnostic = &editor.command_output().unwrap().content;
|
||||
for (width, height) in [(50, 12), (3, 2), (1, 1), (0, 1), (1, 0)] {
|
||||
let area = Rect::new(3, 2, width, height);
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(60, 18),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
draw_diagnostic_panel(
|
||||
frame,
|
||||
diagnostic,
|
||||
area,
|
||||
"shell title",
|
||||
editor.command_output(),
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!terminal.backend().cursor_visible());
|
||||
let text = screen_text(&terminal);
|
||||
assert!(!text.contains("SKIPPED"));
|
||||
assert!(!text.contains('\u{1b}'));
|
||||
if width == 50 {
|
||||
assert!(text.contains("shell title"));
|
||||
assert!(text.contains("VISIBLE"));
|
||||
assert!(!text.contains("abcd"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_output_wrap_toggle_reflows_and_scrolls_without_changing_source() {
|
||||
let mut session = EditorSession::open(None, vec![], None).unwrap();
|
||||
session
|
||||
.editor_mut()
|
||||
.bind("normal", "leader z w", Some("core.word_wrap.toggle".into()))
|
||||
.unwrap();
|
||||
let source_wrap = session.editor().word_wrap();
|
||||
let source = session.editor().lines().to_vec();
|
||||
session.editor_mut().show_command_output(
|
||||
"long output".into(),
|
||||
Err(format!("{}TAIL", "abcdefghij".repeat(30))),
|
||||
);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(42, 14)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
assert!(!screen_text(&terminal).contains("TAIL"));
|
||||
session.handle(Input::Char('w'));
|
||||
assert!(!session.editor().command_output().unwrap().wrapping);
|
||||
for ch in " zw".chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("TAIL"));
|
||||
session.handle(Input::Down);
|
||||
assert_eq!(session.editor().command_output().unwrap().content.top, 1);
|
||||
session.handle(Input::Right);
|
||||
assert_eq!(session.editor().command_output().unwrap().content.left, 0);
|
||||
for ch in " zw".chars() {
|
||||
session.handle(Input::Char(ch));
|
||||
}
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
assert!(!screen_text(&terminal).contains("TAIL"));
|
||||
assert_eq!(session.editor().word_wrap(), source_wrap);
|
||||
assert_eq!(session.editor().lines(), source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_output_wrap_bindings_respect_remaps_disabled_keys_and_sequence_cancellation() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor
|
||||
.bind("normal", "ctrl+y", Some("core.word_wrap.toggle".into()))
|
||||
.unwrap();
|
||||
editor
|
||||
.bind("normal", "ctrl+w z", Some("core.word_wrap.toggle".into()))
|
||||
.unwrap();
|
||||
editor
|
||||
.bind("normal", "leader x", Some("core.delete".into()))
|
||||
.unwrap();
|
||||
editor.show_command_output("command".into(), Err("output".into()));
|
||||
let source = editor.lines().to_vec();
|
||||
editor.handle(Input::Ctrl('y'));
|
||||
assert!(editor.command_output().unwrap().wrapping);
|
||||
editor.bind("normal", "ctrl+y", None).unwrap();
|
||||
editor.handle(Input::Ctrl('y'));
|
||||
assert!(editor.command_output().unwrap().wrapping);
|
||||
editor.handle(Input::Ctrl('w'));
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.command_output().is_some());
|
||||
assert!(editor.sequence_pending().is_none());
|
||||
editor.handle(Input::Ctrl('w'));
|
||||
editor.handle(Input::Char('z'));
|
||||
assert!(!editor.command_output().unwrap().wrapping);
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('x'));
|
||||
assert_eq!(editor.lines(), source);
|
||||
assert!(editor.command_output().is_some());
|
||||
assert!(editor.sequence_pending().is_none());
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.command_output().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_shell_status_is_a_separate_segment_left_of_the_battery() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor.set_battery_charge(Some(27));
|
||||
editor.set_shell_label("sle... | pwd /".into());
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
let top = pane_screen(&terminal, Rect::new(0, 0, 80, 1));
|
||||
assert!(top.contains("░▓ sle... | pwd / ▓░ ░▓ bat 27%"), "{top}");
|
||||
editor.set_shell_label(String::new());
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
let top = pane_screen(&terminal, Rect::new(0, 0, 80, 1));
|
||||
assert!(!top.contains("sle..."));
|
||||
assert!(top.trim_end().ends_with("bat 27%"), "{top}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user