Refactor shell wrapping (#23108)

Conrad Irwin created

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

Change summary

crates/task/src/lib.rs                     | 167 ++++++++++++++
crates/terminal_view/src/terminal_panel.rs | 285 +++--------------------
2 files changed, 213 insertions(+), 239 deletions(-)

Detailed changes

crates/task/src/lib.rs 🔗

@@ -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
+        }
+    }
+}

crates/terminal_view/src/terminal_panel.rs 🔗

@@ -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 task = SpawnInTerminal {
+            command_label,
+            command,
+            args,
+            ..task.clone()
+        };
 
-        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);
+        if task.allow_concurrent_runs && task.use_new_terminal {
+            self.spawn_in_new_terminal(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 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;
         };
-        #[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)
-                }
-            }
+        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;
         }
-        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])
+        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;
                 }
-            }
-        }
-        spawn_task.args = user_args;
-        // Set up shell args unconditionally, as tasks are always spawned inside of a shell.
-
-        ControlFlow::Continue(())
+            }),
+        );
     }
 
     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,
-}