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
+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, ""));