Refactor shell wrapping (#23108)

I want to use this to implement ! in vim, so move it from terminal_view
to task, and split windows/non-windows more cleanly.

Release Notes:

- N/A
This commit is contained in:
Conrad Irwin 2025-01-15 08:45:48 -07:00 committed by GitHub
parent 45198f2af4
commit f50a118e78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 216 additions and 242 deletions

View file

@ -284,3 +284,170 @@ pub enum Shell {
title_override: Option<SharedString>,
},
}
#[cfg(target_os = "windows")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WindowsShellType {
Powershell,
Cmd,
Other,
}
/// ShellBuilder is used to turn a user-requested task into a
/// program that can be executed by the shell.
pub struct ShellBuilder {
program: String,
args: Vec<String>,
}
impl ShellBuilder {
/// Create a new ShellBuilder as configured.
pub fn new(is_local: bool, shell: &Shell) -> Self {
let (program, args) = match shell {
Shell::System => {
if is_local {
(Self::system_shell(), Vec::new())
} else {
("\"${SHELL:-sh}\"".to_string(), Vec::new())
}
}
Shell::Program(shell) => (shell.clone(), Vec::new()),
Shell::WithArguments { program, args, .. } => (program.clone(), args.clone()),
};
Self { program, args }
}
}
#[cfg(not(target_os = "windows"))]
impl ShellBuilder {
/// Returns the label to show in the terminal tab
pub fn command_label(&self, command_label: &str) -> String {
format!("{} -i -c '{}'", self.program, command_label)
}
/// Returns the program and arguments to run this task in a shell.
pub fn build(mut self, task_command: String, task_args: &Vec<String>) -> (String, Vec<String>) {
let combined_command = task_args
.into_iter()
.fold(task_command, |mut command, arg| {
command.push(' ');
command.push_str(&arg);
command
});
self.args
.extend(["-i".to_owned(), "-c".to_owned(), combined_command]);
(self.program, self.args)
}
fn system_shell() -> String {
std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
}
}
#[cfg(target_os = "windows")]
impl ShellBuilder {
/// Returns the label to show in the terminal tab
pub fn command_label(&self, command_label: &str) -> String {
match self.windows_shell_type() {
WindowsShellType::Powershell => {
format!("{} -C '{}'", self.program, command_label)
}
WindowsShellType::Cmd => {
format!("{} /C '{}'", self.program, command_label)
}
WindowsShellType::Other => {
format!("{} -i -c '{}'", self.program, command_label)
}
}
}
/// Returns the program and arguments to run this task in a shell.
pub fn build(mut self, task_command: String, task_args: &Vec<String>) -> (String, Vec<String>) {
let combined_command = task_args
.into_iter()
.fold(task_command, |mut command, arg| {
command.push(' ');
command.push_str(&self.to_windows_shell_variable(arg.to_string()));
command
});
match self.windows_shell_type() {
WindowsShellType::Powershell => self.args.extend(["-C".to_owned(), combined_command]),
WindowsShellType::Cmd => self.args.extend(["/C".to_owned(), combined_command]),
WindowsShellType::Other => {
self.args
.extend(["-i".to_owned(), "-c".to_owned(), combined_command])
}
}
(self.program, self.args)
}
fn windows_shell_type(&self) -> WindowsShellType {
if self.program == "powershell"
|| self.program.ends_with("powershell.exe")
|| self.program == "pwsh"
|| self.program.ends_with("pwsh.exe")
{
WindowsShellType::Powershell
} else if self.program == "cmd" || self.program.ends_with("cmd.exe") {
WindowsShellType::Cmd
} else {
// Someother shell detected, the user might install and use a
// unix-like shell.
WindowsShellType::Other
}
}
// `alacritty_terminal` uses this as default on Windows. See:
// https://github.com/alacritty/alacritty/blob/0d4ab7bca43213d96ddfe40048fc0f922543c6f8/alacritty_terminal/src/tty/windows/mod.rs#L130
fn system_shell() -> String {
"powershell".to_owned()
}
fn to_windows_shell_variable(&self, input: String) -> String {
match self.windows_shell_type() {
WindowsShellType::Powershell => Self::to_powershell_variable(input),
WindowsShellType::Cmd => Self::to_cmd_variable(input),
WindowsShellType::Other => input,
}
}
fn to_cmd_variable(input: String) -> String {
if let Some(var_str) = input.strip_prefix("${") {
if var_str.find(':').is_none() {
// If the input starts with "${", remove the trailing "}"
format!("%{}%", &var_str[..var_str.len() - 1])
} else {
// `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
// which will result in the task failing to run in such cases.
input
}
} else if let Some(var_str) = input.strip_prefix('$') {
// If the input starts with "$", directly append to "$env:"
format!("%{}%", var_str)
} else {
// If no prefix is found, return the input as is
input
}
}
fn to_powershell_variable(input: String) -> String {
if let Some(var_str) = input.strip_prefix("${") {
if var_str.find(':').is_none() {
// If the input starts with "${", remove the trailing "}"
format!("$env:{}", &var_str[..var_str.len() - 1])
} else {
// `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
// which will result in the task failing to run in such cases.
input
}
} else if let Some(var_str) = input.strip_prefix('$') {
// If the input starts with "$", directly append to "$env:"
format!("$env:{}", var_str)
} else {
// If no prefix is found, return the input as is
input
}
}
}

View file

@ -20,7 +20,7 @@ use itertools::Itertools;
use project::{terminals::TerminalKind, Fs, Project, ProjectEntryId};
use search::{buffer_search::DivRegistrar, BufferSearchBar};
use settings::Settings;
use task::{RevealStrategy, RevealTarget, Shell, SpawnInTerminal, TaskId};
use task::{RevealStrategy, RevealTarget, ShellBuilder, SpawnInTerminal, TaskId};
use terminal::{
terminal_settings::{TerminalDockPosition, TerminalSettings},
Terminal,
@ -447,167 +447,68 @@ impl TerminalPanel {
.detach_and_log_err(cx);
}
fn spawn_task(&mut self, spawn_in_terminal: &SpawnInTerminal, cx: &mut ViewContext<Self>) {
let mut spawn_task = spawn_in_terminal.clone();
fn spawn_task(&mut self, task: &SpawnInTerminal, cx: &mut ViewContext<Self>) {
let Ok(is_local) = self
.workspace
.update(cx, |workspace, cx| workspace.project().read(cx).is_local())
else {
return;
};
if let ControlFlow::Break(_) =
Self::fill_command(is_local, spawn_in_terminal, &mut spawn_task)
{
return;
}
let spawn_task = spawn_task;
let allow_concurrent_runs = spawn_in_terminal.allow_concurrent_runs;
let use_new_terminal = spawn_in_terminal.use_new_terminal;
let builder = ShellBuilder::new(is_local, &task.shell);
let command_label = builder.command_label(&task.command_label);
let (command, args) = builder.build(task.command.clone(), &task.args);
if allow_concurrent_runs && use_new_terminal {
self.spawn_in_new_terminal(spawn_task, cx)
.detach_and_log_err(cx);
return;
}
let terminals_for_task = self.terminals_for_task(&spawn_in_terminal.full_label, cx);
if terminals_for_task.is_empty() {
self.spawn_in_new_terminal(spawn_task, cx)
.detach_and_log_err(cx);
return;
}
let (existing_item_index, task_pane, existing_terminal) = terminals_for_task
.last()
.expect("covered no terminals case above")
.clone();
let id = spawn_in_terminal.id.clone();
cx.spawn(move |this, mut cx| async move {
if allow_concurrent_runs {
debug_assert!(
!use_new_terminal,
"Should have handled 'allow_concurrent_runs && use_new_terminal' case above"
);
this.update(&mut cx, |terminal_panel, cx| {
terminal_panel.replace_terminal(
spawn_task,
task_pane,
existing_item_index,
existing_terminal,
cx,
)
})?
.await;
} else {
this.update(&mut cx, |this, cx| {
this.deferred_tasks.insert(
id,
cx.spawn(|terminal_panel, mut cx| async move {
wait_for_terminals_tasks(terminals_for_task, &mut cx).await;
let Ok(Some(new_terminal_task)) =
terminal_panel.update(&mut cx, |terminal_panel, cx| {
if use_new_terminal {
terminal_panel
.spawn_in_new_terminal(spawn_task, cx)
.detach_and_log_err(cx);
None
} else {
Some(terminal_panel.replace_terminal(
spawn_task,
task_pane,
existing_item_index,
existing_terminal,
cx,
))
}
})
else {
return;
};
new_terminal_task.await;
}),
);
})
.ok();
}
anyhow::Result::<_, anyhow::Error>::Ok(())
})
.detach()
}
pub fn fill_command(
is_local: bool,
spawn_in_terminal: &SpawnInTerminal,
spawn_task: &mut SpawnInTerminal,
) -> ControlFlow<()> {
let Some((shell, mut user_args)) = (match spawn_in_terminal.shell.clone() {
Shell::System => {
if is_local {
retrieve_system_shell().map(|shell| (shell, Vec::new()))
} else {
Some(("\"${SHELL:-sh}\"".to_string(), Vec::new()))
}
}
Shell::Program(shell) => Some((shell, Vec::new())),
Shell::WithArguments { program, args, .. } => Some((program, args)),
}) else {
return ControlFlow::Break(());
let task = SpawnInTerminal {
command_label,
command,
args,
..task.clone()
};
#[cfg(target_os = "windows")]
let windows_shell_type = to_windows_shell_type(&shell);
#[cfg(not(target_os = "windows"))]
{
spawn_task.command_label = format!("{shell} -i -c '{}'", spawn_task.command_label);
}
#[cfg(target_os = "windows")]
{
use crate::terminal_panel::WindowsShellType;
match windows_shell_type {
WindowsShellType::Powershell => {
spawn_task.command_label = format!("{shell} -C '{}'", spawn_task.command_label)
}
WindowsShellType::Cmd => {
spawn_task.command_label = format!("{shell} /C '{}'", spawn_task.command_label)
}
WindowsShellType::Other => {
spawn_task.command_label =
format!("{shell} -i -c '{}'", spawn_task.command_label)
}
}
if task.allow_concurrent_runs && task.use_new_terminal {
self.spawn_in_new_terminal(task, cx).detach_and_log_err(cx);
return;
}
let task_command = std::mem::replace(&mut spawn_task.command, shell);
let task_args = std::mem::take(&mut spawn_task.args);
let combined_command = task_args
.into_iter()
.fold(task_command, |mut command, arg| {
command.push(' ');
#[cfg(not(target_os = "windows"))]
command.push_str(&arg);
#[cfg(target_os = "windows")]
command.push_str(&to_windows_shell_variable(windows_shell_type, arg));
command
});
#[cfg(not(target_os = "windows"))]
user_args.extend(["-i".to_owned(), "-c".to_owned(), combined_command]);
#[cfg(target_os = "windows")]
{
use crate::terminal_panel::WindowsShellType;
match windows_shell_type {
WindowsShellType::Powershell => {
user_args.extend(["-C".to_owned(), combined_command])
}
WindowsShellType::Cmd => user_args.extend(["/C".to_owned(), combined_command]),
WindowsShellType::Other => {
user_args.extend(["-i".to_owned(), "-c".to_owned(), combined_command])
}
}
let mut terminals_for_task = self.terminals_for_task(&task.full_label, cx);
let Some(existing) = terminals_for_task.pop() else {
self.spawn_in_new_terminal(task, cx).detach_and_log_err(cx);
return;
};
let (existing_item_index, task_pane, existing_terminal) = existing;
if task.allow_concurrent_runs {
self.replace_terminal(task, task_pane, existing_item_index, existing_terminal, cx)
.detach();
return;
}
spawn_task.args = user_args;
// Set up shell args unconditionally, as tasks are always spawned inside of a shell.
ControlFlow::Continue(())
self.deferred_tasks.insert(
task.id.clone(),
cx.spawn(|terminal_panel, mut cx| async move {
wait_for_terminals_tasks(terminals_for_task, &mut cx).await;
let task = terminal_panel.update(&mut cx, |terminal_panel, cx| {
if task.use_new_terminal {
terminal_panel
.spawn_in_new_terminal(task, cx)
.detach_and_log_err(cx);
None
} else {
Some(terminal_panel.replace_terminal(
task,
task_pane,
existing_item_index,
existing_terminal,
cx,
))
}
});
if let Ok(Some(task)) = task {
task.await;
}
}),
);
}
pub fn spawn_in_new_terminal(
@ -1438,97 +1339,3 @@ impl Render for InlineAssistTabBarButton {
})
}
}
fn retrieve_system_shell() -> Option<String> {
#[cfg(not(target_os = "windows"))]
{
use anyhow::Context;
use util::ResultExt;
std::env::var("SHELL")
.context("Error finding SHELL in env.")
.log_err()
}
// `alacritty_terminal` uses this as default on Windows. See:
// https://github.com/alacritty/alacritty/blob/0d4ab7bca43213d96ddfe40048fc0f922543c6f8/alacritty_terminal/src/tty/windows/mod.rs#L130
#[cfg(target_os = "windows")]
return Some("powershell".to_owned());
}
#[cfg(target_os = "windows")]
fn to_windows_shell_variable(shell_type: WindowsShellType, input: String) -> String {
match shell_type {
WindowsShellType::Powershell => to_powershell_variable(input),
WindowsShellType::Cmd => to_cmd_variable(input),
WindowsShellType::Other => input,
}
}
#[cfg(target_os = "windows")]
fn to_windows_shell_type(shell: &str) -> WindowsShellType {
if shell == "powershell"
|| shell.ends_with("powershell.exe")
|| shell == "pwsh"
|| shell.ends_with("pwsh.exe")
{
WindowsShellType::Powershell
} else if shell == "cmd" || shell.ends_with("cmd.exe") {
WindowsShellType::Cmd
} else {
// Someother shell detected, the user might install and use a
// unix-like shell.
WindowsShellType::Other
}
}
/// Convert `${SOME_VAR}`, `$SOME_VAR` to `%SOME_VAR%`.
#[inline]
#[cfg(target_os = "windows")]
fn to_cmd_variable(input: String) -> String {
if let Some(var_str) = input.strip_prefix("${") {
if var_str.find(':').is_none() {
// If the input starts with "${", remove the trailing "}"
format!("%{}%", &var_str[..var_str.len() - 1])
} else {
// `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
// which will result in the task failing to run in such cases.
input
}
} else if let Some(var_str) = input.strip_prefix('$') {
// If the input starts with "$", directly append to "$env:"
format!("%{}%", var_str)
} else {
// If no prefix is found, return the input as is
input
}
}
/// Convert `${SOME_VAR}`, `$SOME_VAR` to `$env:SOME_VAR`.
#[inline]
#[cfg(target_os = "windows")]
fn to_powershell_variable(input: String) -> String {
if let Some(var_str) = input.strip_prefix("${") {
if var_str.find(':').is_none() {
// If the input starts with "${", remove the trailing "}"
format!("$env:{}", &var_str[..var_str.len() - 1])
} else {
// `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
// which will result in the task failing to run in such cases.
input
}
} else if let Some(var_str) = input.strip_prefix('$') {
// If the input starts with "$", directly append to "$env:"
format!("$env:{}", var_str)
} else {
// If no prefix is found, return the input as is
input
}
}
#[cfg(target_os = "windows")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WindowsShellType {
Powershell,
Cmd,
Other,
}