feat: run shell commands asynchronously from command mode

This commit is contained in:
2026-10-18 00:30:24 +02:00
parent f942f881e6
commit a5f92452b3
16 changed files with 817 additions and 13 deletions
+2 -1
View File
@@ -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
View File
@@ -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, ""));
+1
View File
@@ -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;
+2
View File
@@ -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
+50
View File
@@ -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
View File
@@ -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;
+42 -6
View File
@@ -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")
.style(Style::default().fg(Color::DarkGray)),
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