feat: run shell commands asynchronously from command mode
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
- [x] command autocompletion via `tab` (multiple presses cycles candidates)
|
||||
- [x] fix ruby linting for the vamp config file (see attached image)
|
||||
- [ ] execute terminal commands asynchronously using the `!` prefix (e.g. `!pwd`) and display the output in a floating window.
|
||||
- [x] execute terminal commands asynchronously using the `!` prefix (e.g. `!pwd`) and display the output in a floating window.
|
||||
- while waiting on the result of the executed command, show a spinner in a new element right 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.
|
||||
|
||||
@@ -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. A private Unix process group is killed and reaped on worker drop or shell completion. Session owns one running worker and its spinner; Editor owns the read-only output popup 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.
|
||||
|
||||
@@ -119,7 +119,7 @@ config.bind "leader s", config.core.diff.orientation.toggle
|
||||
|
||||
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: [:battery, :shell]` 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|
|
||||
@@ -130,7 +130,7 @@ Vamp.configure do |config|
|
||||
position: { padding: 1, leading_margin: 1, trailing_margin: 0, leading_edge: "░▓", trailing_edge: "" }
|
||||
}
|
||||
config.bar.bottom = {
|
||||
left: [:tabs], center: [], right: [:battery],
|
||||
left: [:tabs], center: [], right: [:battery, :shell],
|
||||
tabs: {
|
||||
title: "{index} {view}{name}{modified}", padding: 1,
|
||||
leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: ""
|
||||
@@ -140,7 +140,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.
|
||||
|
||||
@@ -160,6 +160,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` shows the pending `:!COMMAND` abbreviation and animated spinner, and hides when idle. It defaults to the right of `:battery`; 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 indicator.
|
||||
|
||||
### Command and search popup anchors
|
||||
|
||||
Configure the two input windows independently:
|
||||
|
||||
@@ -44,6 +44,7 @@ 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.
|
||||
|
||||
@@ -198,6 +198,10 @@ Tab replaces the command-name prefix with the first case-sensitive match in alph
|
||||
|
||||
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 a shell command 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. One command can run at a time; another `:!` is rejected until it finishes.
|
||||
|
||||
While running, the default top bar shows the first three command graphemes, `...` when truncated, and an animated spinner to the right of the battery. Completion opens a read-only floating window with the command, exit status (or launch error), stdout, and separately marked stderr. Arrows/Ctrl+h/j/k/l and the mouse wheel scroll; Home resets, End goes to the last line, and Enter/Escape dismisses it and resumes the underlying editing mode. 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 the running process group, and background jobs in that group are also stopped when the command exits. Shell execution currently requires Unix.
|
||||
|
||||
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: [:battery, :shell],
|
||||
tabs: {
|
||||
title: "{index} {view}{name}{modified}", padding: 1,
|
||||
leading_edge: "░▓", trailing_edge: "▓░", first_leading_edge: ""
|
||||
|
||||
+2
-1
@@ -130,10 +130,11 @@ impl ChromeLine {
|
||||
Self {
|
||||
left: vec!["tabs".into()],
|
||||
center: Vec::new(),
|
||||
right: vec!["battery".into()],
|
||||
right: vec!["battery".into(), "shell".into()],
|
||||
segments: BTreeMap::from([
|
||||
("tabs".into(), default_segment("tabs")),
|
||||
("battery".into(), default_segment("battery")),
|
||||
("shell".into(), default_segment("shell")),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
+128
@@ -137,6 +137,11 @@ pub struct StartupDiagnostic {
|
||||
max_column: usize,
|
||||
}
|
||||
|
||||
pub(crate) struct ShellOutput {
|
||||
pub(crate) title: String,
|
||||
pub(crate) content: StartupDiagnostic,
|
||||
}
|
||||
|
||||
pub struct Hover {
|
||||
text: String,
|
||||
line_starts: Vec<usize>,
|
||||
@@ -2223,6 +2228,8 @@ pub struct Editor {
|
||||
language_tab_spaces: BTreeMap<String, Option<NonZeroU8>>,
|
||||
battery_charge: Option<u8>,
|
||||
battery_label: String,
|
||||
shell_label: String,
|
||||
command_output: Option<ShellOutput>,
|
||||
git_blame: bool,
|
||||
gutter_style: crate::gutter::GutterStyle,
|
||||
line_blame: Option<(BlameSource, Arc<crate::blame::LineBlame>, String)>,
|
||||
@@ -2339,6 +2346,8 @@ impl Editor {
|
||||
language_tab_spaces: BTreeMap::new(),
|
||||
battery_charge: None,
|
||||
battery_label: String::new(),
|
||||
shell_label: String::new(),
|
||||
command_output: None,
|
||||
git_blame: true,
|
||||
gutter_style: crate::gutter::GutterStyle::default(),
|
||||
line_blame: None,
|
||||
@@ -4698,6 +4707,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)
|
||||
@@ -6914,6 +6924,107 @@ 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();
|
||||
self.command_output = Some(ShellOutput {
|
||||
title: format!("{status} · !{command}"),
|
||||
content: StartupDiagnostic {
|
||||
lines,
|
||||
top: 0,
|
||||
left: 0,
|
||||
max_column,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_command_output(&mut self, input: &Input) -> bool {
|
||||
let Some(output) = &mut self.command_output else {
|
||||
return false;
|
||||
};
|
||||
let content = &mut output.content;
|
||||
match input {
|
||||
Input::Escape | Input::Enter => self.command_output = None,
|
||||
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(content.lines.len() - 1);
|
||||
}
|
||||
Input::Left | Input::Ctrl('h') => content.left = content.left.saturating_sub(1),
|
||||
Input::Right | Input::Ctrl('l') => {
|
||||
content.left = content.left.saturating_add(1).min(content.max_column);
|
||||
}
|
||||
Input::Home => {
|
||||
content.top = 0;
|
||||
content.left = 0;
|
||||
}
|
||||
Input::End => content.top = content.lines.len() - 1,
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
pub(crate) fn gutter_style(&self) -> crate::gutter::GutterStyle {
|
||||
self.gutter_style
|
||||
}
|
||||
@@ -7975,6 +8086,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;
|
||||
}
|
||||
@@ -9544,6 +9658,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, ""));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -61,6 +61,8 @@ Command: Tab/Shift+Tab completes command names and cycles forward/backward with
|
||||
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
|
||||
|
||||
@@ -468,6 +468,7 @@ pub struct EditorSession {
|
||||
picker_source: Option<PathBuf>,
|
||||
battery: Option<crate::battery::Battery>,
|
||||
battery_enabled: bool,
|
||||
shell: Option<(String, crate::shell::Worker, Instant)>,
|
||||
last_file_refresh: Instant,
|
||||
last_diff_refresh: Instant,
|
||||
pub(crate) diff_follow_working_copy: bool,
|
||||
@@ -595,6 +596,7 @@ impl EditorSession {
|
||||
picker_source: None,
|
||||
battery: None,
|
||||
battery_enabled: false,
|
||||
shell: None,
|
||||
last_file_refresh: Instant::now(),
|
||||
last_diff_refresh: Instant::now(),
|
||||
diff_follow_working_copy: config.diff_follow_working_copy,
|
||||
@@ -827,6 +829,25 @@ impl EditorSession {
|
||||
|
||||
fn dispatch(&mut self, invocation: Invocation) {
|
||||
if invocation.plugin == "core" {
|
||||
if invocation.command == "shell" {
|
||||
if self.shell.is_some() {
|
||||
self.editor
|
||||
.set_message("A shell command is already running");
|
||||
return;
|
||||
}
|
||||
let command = invocation.args;
|
||||
match crate::shell::Worker::start(
|
||||
command.clone(),
|
||||
self.editor.project_root().to_path_buf(),
|
||||
) {
|
||||
Ok(worker) => {
|
||||
self.shell = Some((command, worker, Instant::now()));
|
||||
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 +1771,34 @@ impl EditorSession {
|
||||
changed
|
||||
}
|
||||
|
||||
fn poll_shell_at(&mut self, now: Instant) -> bool {
|
||||
let Some((command, worker, started)) = &mut self.shell else {
|
||||
return false;
|
||||
};
|
||||
if let Some(result) = worker.poll() {
|
||||
let command = command.clone();
|
||||
self.shell = None;
|
||||
self.editor.set_shell_label(String::new());
|
||||
self.editor.show_command_output(command, result);
|
||||
return true;
|
||||
}
|
||||
const FRAMES: [char; 4] = ['|', '/', '-', '\\'];
|
||||
let frame =
|
||||
FRAMES[((now.saturating_duration_since(*started).as_millis() / 100) % 4) as usize];
|
||||
if self.editor.shell_label().ends_with(frame) {
|
||||
return false;
|
||||
}
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
let mut graphemes = command.trim().graphemes(true);
|
||||
let mut label: String = graphemes.by_ref().take(3).collect();
|
||||
if graphemes.next().is_some() {
|
||||
label.push_str("...");
|
||||
}
|
||||
label.push(' ');
|
||||
label.push(frame);
|
||||
self.editor.set_shell_label(label)
|
||||
}
|
||||
|
||||
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 +1826,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;
|
||||
+41
-5
@@ -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.
|
||||
@@ -2839,6 +2843,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 +2857,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 +2905,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, false);
|
||||
}
|
||||
}
|
||||
|
||||
struct DiffGeometry {
|
||||
@@ -4622,6 +4631,16 @@ 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", true);
|
||||
}
|
||||
|
||||
fn draw_diagnostic_panel(
|
||||
frame: &mut Frame,
|
||||
diagnostic: &StartupDiagnostic,
|
||||
area: Rect,
|
||||
title: &str,
|
||||
startup: bool,
|
||||
) {
|
||||
let panel = floating_panel(area);
|
||||
frame.render_widget(Clear, panel);
|
||||
let bordered = panel.width >= 4 && panel.height >= 4;
|
||||
@@ -4631,8 +4650,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);
|
||||
@@ -4658,11 +4677,15 @@ fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, ar
|
||||
.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 +5569,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
|
||||
|
||||
@@ -4,6 +4,77 @@ use std::{
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
#[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.as_mut().unwrap().2 = fixed;
|
||||
assert!(session.poll_shell_at(fixed + Duration::from_millis(100)));
|
||||
assert_eq!(session.editor.shell_label(), "whi... /");
|
||||
session.execute("!printf should-not-run > unexpected");
|
||||
assert_eq!(
|
||||
session.editor.message(),
|
||||
"A shell command is already running"
|
||||
);
|
||||
assert!(!fixture.0.join("unexpected").exists());
|
||||
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_none());
|
||||
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(()));
|
||||
}
|
||||
@@ -15354,3 +15354,93 @@ 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", false);
|
||||
})
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user