use std::borrow::Cow; #[cfg(feature = "local_tty")] use std::collections::{HashMap, HashSet}; #[cfg(feature = "local_tty")] use std::path::Path; use std::path::PathBuf; use std::sync::Arc; #[cfg(feature = "local_tty")] use galaxyui::{AppContext, ModelContext}; use galaxyui::{Entity, SingletonEntity}; #[cfg(feature = "local_tty")] use settings::Setting as _; #[cfg(feature = "local_tty")] use crate::util::path::file_exists_and_is_executable; use super::{ session_settings::{NewSessionShell, StartupShell}, shell::ShellType, ShellLaunchData, }; #[derive(Debug, PartialEq, Eq, Hash)] struct LocalConfig { command: String, executable_path: PathBuf, shell_type: ShellType, } impl TryFrom for LocalConfig { type Error = (); #[cfg(feature = "local_tty")] fn try_from(value: StartupShell) -> Result { use crate::terminal::local_tty::shell::supported_shell_path_and_type; let command = value.shell_command().ok_or(())?; let (path, shell_type) = supported_shell_path_and_type(command).ok_or(())?; Ok(Self { command: command.to_string(), executable_path: path.to_path_buf(), shell_type, }) } #[cfg(not(feature = "local_tty"))] fn try_from(_value: StartupShell) -> Result { Err(()) } } /// The state for the AvailableShell model. Is kept private to the module, b/c we do not want people /// manually destructuring or matching on this data. #[derive(Debug, PartialEq, Eq, Hash)] enum Config { SystemDefault, #[cfg_attr(not(feature = "local_tty"), allow(dead_code))] KnownLocal(LocalConfig), #[cfg_attr(not(feature = "local_tty"), allow(dead_code))] Wsl { distro: String, }, #[cfg_attr(any(not(feature = "local_tty"), unix), allow(dead_code))] MSYS2(LocalConfig), #[cfg_attr(not(feature = "local_tty"), allow(dead_code))] Custom(LocalConfig), /// A shell running inside a Docker sandbox via `sbx run`. /// /// Unlike local shells, we don't pick the shell binary or path here: the /// shell that actually runs comes from the container image. This mirrors /// how [`Config::Wsl`] carries just the distro name and defers the shell /// choice to WSL itself. #[cfg_attr(not(feature = "local_tty"), allow(dead_code))] DockerSandbox { /// Path to the `sbx` CLI binary on the host. sbx_path: PathBuf, /// Base Docker image to use when creating the sandbox (passed as /// `sbx run --template `). `None` means "use sbx's default /// image". base_image: Option, }, } // The concept of specifying an available shell does not exist on non-local filesystems. So we allow // dead code so that the concept of the struct can exist, but remove any methods that do anything // with it. That way, method calls can still take `Option` as an argument, but // remote_ttys can just specify `None` for the value. #[cfg_attr(not(feature = "local_tty"), allow(dead_code,))] /// Contains the config describing a 'shell' that can be launched for a new session. Currently falls /// into 4 categories: /// - Known Local: A shell that is known to be installed on the local filesystem, and can be run /// by invoking an executable. /// - Known WSL: A WSL distro that can be launched by invoking WSL with a specific distro flag /// - Custom: A user-specified custom executable that cna be run locally. /// - System Default: Uses the default shell for a given system. /// /// All state is stored in an Arc so that it can be safely and easily copied. In general, unless you /// are using a custom shell, you should not be constructing this struct directly. Instead, use the /// methods available on the `AvailableShells` model. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct AvailableShell { id: Option, state: Arc, } impl AvailableShell { /// Returns an "id" associated with a known shell. If /// shell1.is_some_and(|id1| shell2.is_some_and(|id2| id1 == id2)) holds, /// then shell1 and shell2 are the same shell. pub fn id(&self) -> Option<&str> { self.id.as_deref() } pub fn short_name(&self) -> Cow<'_, str> { match self.state.as_ref() { Config::SystemDefault => Cow::from("Default"), Config::KnownLocal(LocalConfig { command, .. }) | Config::MSYS2(LocalConfig { command, .. }) => match command.as_str() { "bash" => Cow::from("Bash"), "zsh" => Cow::from("Zsh"), "fish" => Cow::from("Fish"), "pwsh" | "pwsh.exe" => Cow::from("PowerShell"), "powershell" | "powershell.exe" => Cow::from("Windows PowerShell"), _ => Cow::from(command), }, Config::Wsl { distro } => Cow::from(distro), Config::Custom(_) => Cow::from("Custom"), Config::DockerSandbox { .. } => Cow::from("Docker Sandbox"), } } pub fn details(&self) -> Cow<'_, str> { match self.state.as_ref() { Config::SystemDefault => Cow::from("System default shell"), Config::KnownLocal(LocalConfig { executable_path, .. }) | Config::MSYS2(LocalConfig { executable_path, .. }) => Cow::from(format!("{}", executable_path.display())), Config::Wsl { .. } => Cow::from("Windows Subsystem for Linux"), Config::Custom(LocalConfig { executable_path, .. }) => Cow::from(format!("Custom: {}", executable_path.display())), Config::DockerSandbox { .. } => Cow::from("Docker Sandbox"), } } /// Returns a telemetry string that represents the value of the shell without any PII involved. pub fn telemetry_value(&self) -> String { // NOTE: These explicitly do not log the shell path or wsl name, as those can contain PII. match self.state.as_ref() { Config::SystemDefault => "SystemDefault".to_string(), Config::KnownLocal(config) | Config::MSYS2(config) => config.command.clone(), Config::Wsl { .. } => "WSL".to_string(), Config::Custom(_) => "Custom".to_string(), Config::DockerSandbox { .. } => "DockerSandbox".to_string(), } } pub fn wsl_distro(&self) -> Option { match self.state.as_ref() { Config::Wsl { distro } => Some(distro.clone()), _ => None, } } pub fn is_wsl(&self) -> bool { matches!(self.state.as_ref(), Config::Wsl { .. }) } } #[cfg(feature = "local_tty")] impl AvailableShell { /// The long name of the shell. For local shells, this includes the path to /// the executable. fn long_name(&self) -> String { match &self.state.as_ref() { Config::SystemDefault => "Default".to_string(), Config::KnownLocal(LocalConfig { executable_path, .. }) => format!("{} ({})", self.short_name(), executable_path.display()), Config::Wsl { distro } => distro.to_string(), Config::Custom(LocalConfig { command, .. }) => format!("Custom ({command})"), Config::MSYS2(LocalConfig { executable_path, .. }) => format!("{} ({})", self.short_name(), executable_path.display()), Config::DockerSandbox { .. } => "Docker Sandbox".to_string(), } } /// If the shell is a Custom shell, returns the custom shell path. Otherwise returns None. Can /// also be used to assert whether or not the shell is a custom shell using `is_some()`. pub fn get_custom_path(&self) -> Option { if let Config::Custom(config) = self.state.as_ref() { Some(config.executable_path.display().to_string()) } else { None } } fn matches_preference(&self, preference: &NewSessionShell) -> bool { match preference { NewSessionShell::SystemDefault => matches!(self.state.as_ref(), Config::SystemDefault), NewSessionShell::Executable(path) => { matches!(self.state.as_ref(), Config::KnownLocal(LocalConfig { executable_path, .. }) if executable_path == Path::new(path)) } NewSessionShell::WSL(distro) => { matches!(self.state.as_ref(), Config::Wsl { distro: d } if d == distro) } NewSessionShell::Custom(path) => { matches!(self.state.as_ref(), Config::Custom(LocalConfig { executable_path, .. }) if executable_path == Path::new(path)) } NewSessionShell::MSYS2(path) => { matches!(self.state.as_ref(), Config::MSYS2(LocalConfig{ executable_path, .. } ) if executable_path == Path::new(path)) } } } /// Returns the launch data for a shell. /// /// Our conversion methodology is as follows: /// /// | [`AvailableShell`] | Validation | [`Option`] | /// |:---------------------------|:----------:|-----------------------------------:| /// | [`Config::SystemDefault`] | No | [`Option::None`] | /// | [`Config::KnownLocal`] | Yes | [`ShellLaunchData::Executable`] | /// | [`Config::Wsl`] | No | [`ShellLaunchData::WSL`] | /// | [`Config::Custom`] | Yes | [`ShellLaunchData::Executable`] | /// | [`Config::MSYS2`] | No | [`ShellLaunchData::MSYS2`] | /// | [`Config::DockerSandbox`] | No | [`ShellLaunchData::DockerSandbox`] | /// /// For `KnownLocal` and `Custom` we validate that the executable path /// is still valid. pub fn get_valid_shell_path_and_type(&self) -> Option { match self.state.as_ref() { Config::SystemDefault => None, Config::KnownLocal(LocalConfig { executable_path, shell_type, .. }) | Config::Custom(LocalConfig { executable_path, shell_type, .. }) => { // We already did the supported_shell_path_and_type when constructing the model, but // in case the model is out of date we want to verify that the exe is still there. if file_exists_and_is_executable(executable_path) { Some(ShellLaunchData::Executable { executable_path: executable_path.clone(), shell_type: *shell_type, }) } else { None } } Config::Wsl { distro } => Some(ShellLaunchData::WSL { distro: distro.to_string(), }), Config::MSYS2(local_config) => Some(ShellLaunchData::MSYS2 { executable_path: local_config.executable_path.clone(), shell_type: local_config.shell_type, }), Config::DockerSandbox { sbx_path, base_image, } => Some(ShellLaunchData::DockerSandbox { sbx_path: sbx_path.clone(), base_image: base_image.clone(), }), } } fn new_local_executable( command: String, executable_path: PathBuf, shell_type: ShellType, ) -> Self { Self { id: Some(format!("local:{}", executable_path.display())), state: Arc::new(Config::KnownLocal(LocalConfig { command, executable_path, shell_type, })), } } #[cfg(windows)] fn new_msys2(command: String, executable_path: PathBuf, shell_type: ShellType) -> Self { Self { id: Some(format!("msys2:{}", executable_path.display())), state: Arc::new(Config::MSYS2(LocalConfig { command, executable_path, shell_type, })), } } #[cfg_attr(not(windows), allow(dead_code))] fn new_wsl(distro: String) -> Self { Self { id: Some(format!("wsl:{distro}")), state: Arc::new(Config::Wsl { distro }), } } pub(crate) fn new_custom_shell( command: String, executable_path: PathBuf, shell_type: ShellType, ) -> Self { Self { id: None, state: Arc::new(Config::Custom(LocalConfig { command, executable_path, shell_type, })), } } pub(crate) fn new_docker_sandbox_shell(sbx_path: PathBuf, base_image: Option) -> Self { Self { id: None, state: Arc::new(Config::DockerSandbox { sbx_path, base_image, }), } } pub fn is_docker_sandbox(&self) -> bool { matches!(self.state.as_ref(), Config::DockerSandbox { .. }) } } impl From for NewSessionShell { fn from(value: AvailableShell) -> Self { match value.state.as_ref() { Config::SystemDefault => NewSessionShell::SystemDefault, Config::KnownLocal(LocalConfig { executable_path, .. }) => NewSessionShell::Executable(executable_path.display().to_string()), Config::Wsl { distro } => NewSessionShell::WSL(distro.clone()), Config::Custom(LocalConfig { executable_path, .. }) => NewSessionShell::Custom(executable_path.display().to_string()), Config::MSYS2(local_config) => { NewSessionShell::MSYS2(local_config.executable_path.display().to_string()) } // Docker sandbox isn't a persistable "preferred shell" today — // it's always launched on-demand via a tab action or slash // command. Round-trip through settings falls back to the system // default. // TODO(advait): If we ever let users pin the sandbox as their // default shell, add a `NewSessionShell::DockerSandbox` variant. Config::DockerSandbox { .. } => NewSessionShell::SystemDefault, } } } impl From for StartupShell { fn from(value: AvailableShell) -> Self { match value.state.as_ref() { Config::SystemDefault | Config::Wsl { .. } => StartupShell::Default, Config::KnownLocal(LocalConfig { shell_type, .. }) => { StartupShell::from(Some(shell_type.name().to_string())) } Config::Custom(LocalConfig { executable_path, .. }) => StartupShell::Custom(executable_path.display().to_string()), Config::MSYS2(local_config) => { StartupShell::from(Some(local_config.shell_type.name().to_string())) } // See the matching comment on `From for // NewSessionShell`: the sandbox isn't persistable as a startup // shell today, so fall back to default. Config::DockerSandbox { .. } => StartupShell::Default, } } } impl Default for AvailableShell { fn default() -> Self { Self { id: None, state: Arc::new(Config::SystemDefault), } } } #[cfg(feature = "local_tty")] impl TryFrom<&str> for AvailableShell { type Error = (); fn try_from(value: &str) -> Result { use crate::terminal::local_tty::shell::supported_shell_path_and_type; let (path, shell_type) = supported_shell_path_and_type(value).ok_or(())?; let command = path .file_name() .and_then(|file_name| file_name.to_str()) .ok_or(())? .to_string(); Ok(Self::new_custom_shell(command, path, shell_type)) } } #[cfg(feature = "local_tty")] impl TryFrom for AvailableShell { type Error = (); fn try_from(value: NewSessionShell) -> Result { if let NewSessionShell::Custom(path) = &value { Ok(AvailableShell::try_from(path.as_str())?) } else { Err(()) } } } #[cfg_attr(not(feature = "local_tty"), allow(dead_code))] pub struct AvailableShells { shells: Vec, /// A map of shell name to the number of times it appears in the list. #[cfg(feature = "local_tty")] shell_counts: HashMap, } #[cfg(not(feature = "local_tty"))] impl AvailableShells { pub fn get_available_shells(&self) -> impl Iterator { std::iter::empty() } pub fn get_from_shell_launch_data(&self, _config: &ShellLaunchData) -> Option { None } } #[cfg(feature = "local_tty")] impl AvailableShells { pub fn new(_ctx: &mut ModelContext) -> Self { let fallback_shells_path = cfg!(unix).then_some(Path::new("/etc/shells")); let env_path = std::env::var_os("PATH").unwrap_or_default(); #[cfg_attr(not(windows), allow(unused_mut))] let mut paths_to_search = std::env::split_paths(&env_path).collect::>(); // The PATH here is limited since it doesn't include the locations added // by the user's login shell. We add the Homebrew installer locations to // the search paths so we can detect shells installed via Homebrew. #[cfg(target_os = "macos")] { // Apple Silicon homebrew path paths_to_search.push(PathBuf::from("/opt/homebrew/bin")); // Intel homebrew path paths_to_search.push(PathBuf::from("/usr/local/bin")); } // The user may have installed PowerShell in locations not in the system // path. We add some typical install locations for PowerShell to the paths // to search. #[cfg(windows)] { use crate::util::windows; paths_to_search.extend(windows::powershell_7_install_paths()); paths_to_search.push(windows::powershell_5_install_path()); } #[cfg_attr(not(windows), allow(unused_mut))] let mut shells = Self::load_known_shells(&paths_to_search, fallback_shells_path); #[cfg(windows)] { let ctx = _ctx; shells.extend( super::wsl::WslInfo::as_ref(ctx) .distributions() .map(|distro| AvailableShell::new_wsl(distro.name.to_owned())), ); } let shell_counts = shells.iter().fold(HashMap::new(), |mut counts, shell| { let count = counts.entry(shell.short_name().to_string()).or_insert(0); *count += 1; counts }); Self { shells, shell_counts, } } /// Returns an iterable that iterates over all available shells. pub fn get_available_shells(&self) -> impl Iterator { self.shells.iter() } /// Returns the display name for a shell, context aware of the other /// available shells. If the shell appears only once, we return the short /// name. If the shell appears multiple times, we disambiguate by showing /// the full name with the path to the executable. pub fn display_name_for_shell<'a>(&self, shell: &'a AvailableShell) -> Cow<'a, str> { if self .shell_counts .get::(shell.short_name().as_ref()) .is_some_and(|count| *count > 1) { Cow::from(shell.long_name()) } else { shell.short_name() } } /// Attempts to convert from ShellLaunchData into an AvailableShell /// /// **Note: This is here b/c we need to be able to get an AvailableShell /// from snapshot data. You probably should not be calling this.** /// /// The methodology is: /// /// 1. Search for a matching shell in the list of AvailableShells. /// If it exists, then we return it. This covers all `KnownLocal` or `WSL` cases. /// 2. If we don't find it in the list, and the ShellLaunchData is `WSL`, that means /// the wsl distro is no longer valid, so we return None. /// 3. If we have `Executable`` data that has not matched the list, we convert it to /// a custom shell. This covers the case of either a Custom shell or a KnownLocal /// that for whatever reason is no longer valid. An invalid Custom or KnownLocal /// will still be validated before launching a shell (and fall back to default), /// so it is fine to return a Custom here. /// /// See [`AvailableShell::get_valid_shell_path_and_type`] for more information on how /// we generate these launch configs in the other direction. pub fn get_from_shell_launch_data(&self, config: &ShellLaunchData) -> Option { self.shells .iter() .find(|shell| match (shell.state.as_ref(), config) { (Config::Wsl { distro }, ShellLaunchData::WSL { distro: d }) => distro == d, ( Config::MSYS2(LocalConfig { executable_path, .. }), ShellLaunchData::MSYS2 { executable_path: p, .. }, ) => executable_path == p, ( Config::KnownLocal(LocalConfig { executable_path, shell_type, .. }), ShellLaunchData::Executable { executable_path: p, shell_type: t, }, ) => executable_path == p && shell_type == t, _ => false, }) .cloned() .or_else(|| { if let ShellLaunchData::Executable { executable_path, shell_type, } = config { Some(AvailableShell::new_custom_shell( executable_path.file_name()?.to_str()?.to_string(), executable_path.clone(), *shell_type, )) } else { None } }) } /// Checks the user preferences for the shell to use for new sessions, returning an available /// shell object. pub fn get_user_preferred_shell(&self, ctx: &AppContext) -> AvailableShell { let preference = self.get_user_preferred_shell_setting(ctx); match preference { NewSessionShell::SystemDefault => AvailableShell::default(), NewSessionShell::Custom(_) => AvailableShell::try_from(preference).unwrap_or_default(), // TODO(DAN): This should be cached in the model. Also handle custom preference => self .shells .iter() .find(|shell| shell.matches_preference(&preference)) .cloned() .unwrap_or_default(), } } /// Sets the user-preferred shell for new sessions. Saves the value back to user settings. pub fn set_user_preferred_shell( &self, value: AvailableShell, ctx: &mut ModelContext, ) -> anyhow::Result<()> { use super::session_settings::SessionSettings; use galaxy_core::features::FeatureFlag; SessionSettings::handle(ctx).update(ctx, |settings, ctx| { if FeatureFlag::ShellSelector.is_enabled() { settings .new_session_shell_override .set_value(Some(NewSessionShell::from(value)), ctx) } else { settings .startup_shell_override .set_value(StartupShell::from(value), ctx) } }) } fn load_known_shells( paths_to_search: &[PathBuf], fallback_path: Option<&Path>, ) -> Vec { use galaxy_core::features::FeatureFlag; if !FeatureFlag::ShellSelector.is_enabled() { return vec![ StartupShell::Zsh, StartupShell::Bash, StartupShell::Fish, StartupShell::PowerShell, ] .into_iter() .filter_map(|shell| { let state = LocalConfig::try_from(shell).ok()?; Some(AvailableShell { id: None, state: Arc::new(Config::KnownLocal(state)), }) }) .collect(); } let shell_types = Self::get_shell_types(); let mut known_shells = vec![]; let mut fallback_shell_map = fallback_path .and_then(|path| Self::load_fallback_shells(path, &shell_types).ok()) .unwrap_or_default(); for (shell_type, command_name) in shell_types { #[cfg(windows)] if ["bash.exe", "fish.exe", "zsh.exe"].contains(&command_name) { let msys2_executables = Self::locate_msys2_executables(); for msys2_path in &msys2_executables { if msys2_path.ends_with(command_name) { known_shells.push(AvailableShell::new_msys2( command_name.to_string(), msys2_path.clone(), shell_type, )); } } continue; } let mut fallback_shells = fallback_shell_map.remove(command_name).unwrap_or_default(); for path in Self::resolve_all_executables(command_name, paths_to_search.iter()) { fallback_shells.remove(&path); known_shells.push(AvailableShell::new_local_executable( command_name.to_string(), path, shell_type, )); } // We append shells found in /etc/shells but not the path after the shells found on the path. for path in fallback_shells.iter() { if file_exists_and_is_executable(path) { known_shells.push(AvailableShell::new_local_executable( command_name.to_string(), path.clone(), shell_type, )); } } } known_shells } fn get_shell_types() -> Vec<(ShellType, &'static str)> { if cfg!(windows) { vec![ (ShellType::PowerShell, "pwsh.exe"), (ShellType::PowerShell, "powershell.exe"), (ShellType::Zsh, "zsh.exe"), (ShellType::Bash, "bash.exe"), (ShellType::Fish, "fish.exe"), ] } else { vec![ (ShellType::Zsh, "zsh"), (ShellType::Bash, "bash"), (ShellType::Fish, "fish"), (ShellType::PowerShell, "pwsh"), ] } } /// Looks for UNIX shells running "natively" on Windows via MSYS2. /// /// Attempts to find Git Bash executables at a few locations: /// 1. `$env:LocalAppData\Programs\Git\usr\bin` /// 2. `$env:ProgramFiles\Git\usr\bin` /// 3. Where scoop may have installed it /// We don't want to search the PATH for a `bash.exe` since there are false positives. /// Also search for bash/fish/zsh in the #[cfg(windows)] fn locate_msys2_executables() -> Vec { use std::env; use galaxy_core::features::FeatureFlag; let mut paths = Vec::new(); // We look for Git Bash at `$env:LocalAppData\Programs\Git\usr\bin`. match env::var("LocalAppData") { Ok(local_app_data_path) => { let user_location = Path::new(&local_app_data_path) .join("Programs") .join("Git") .join("usr") .join("bin") .join("bash.exe"); if file_exists_and_is_executable(&user_location) { paths.push(user_location); } } Err(err) => { log::warn!("Environment variable LocalAppData not found {err:#}"); } } // Next, we look for Git Bash at `$env:ProgramFiles\Git\usr\bin`. match env::var("ProgramFiles") { Ok(program_files_path) => { let global_location = Path::new(&program_files_path) .join("Git") .join("usr") .join("bin") .join("bash.exe"); if file_exists_and_is_executable(&global_location) { paths.push(global_location); } } Err(err) => { log::warn!("Environment variable ProgramFiles not found {err:#}"); } } // Next, we look for Git Bash where scoop, a package manager, may have installed it at // `$env:USERPROFILE\scoop\apps\git\current\usr\bin`. // Note that `current` is a symbolic link to the folder with the latest version. if let Ok(user_profile) = env::var("USERPROFILE") { let maybe_scoop_path = Path::new(&user_profile) .join("scoop") .join("apps") .join("git") .join("current") .join("usr") .join("bin") .join("bash.exe"); if file_exists_and_is_executable(&maybe_scoop_path) { paths.push(maybe_scoop_path); } } if FeatureFlag::MSYS2Shells.is_enabled() { // Search the default install location for MSYS2 shells (installed via pacman). match env::var("SystemDrive") { Ok(system_drive) => { let msys_bin = Path::new(&format!(r"{system_drive}\")) .join("msys64") .join("usr") .join("bin"); for shell in ["bash.exe", "fish.exe", "zsh.exe"] { let msys_shell = msys_bin.join(shell); if file_exists_and_is_executable(&msys_shell) { paths.push(msys_shell); } } } Err(err) => { log::warn!("Environment variable SystemDrive not found {err:#}"); } } } paths } /// Resolves all full paths to executables of the given command name in PATH. /// /// `paths_to_search` should contain the locations in PATH along with any /// manually added paths that we want to search. fn resolve_all_executables<'a>( command: &str, paths_to_search: impl Iterator, ) -> Vec { use itertools::Itertools as _; paths_to_search .filter_map(|single_path| { let joined = single_path.join(command); let canonicalized = dunce::canonicalize(&joined).unwrap_or(joined); file_exists_and_is_executable(&canonicalized).then_some(canonicalized) }) .unique() .collect() } fn load_fallback_shells( path: &Path, shell_types: &[(ShellType, &str)], ) -> anyhow::Result>> { use std::fs::File; use std::io::{BufRead, BufReader}; let mut shells = HashMap::new(); let file = File::open(path)?; for (_, exe) in shell_types.iter() { shells.insert(exe.to_string(), HashSet::new()); } let reader = BufReader::new(file); for line in reader.lines() { let line = line?; // For each line in /etc/shells, we check: // - does it not start with a #? // - is it not empty? // - does the "file_name" map to a shell that we support? // // If all of those are true, then we add it to the set of paths associated with that shell if !line.trim_start().starts_with('#') && !line.trim().is_empty() { let Ok(path) = dunce::canonicalize(line) else { continue; }; if let Some(file_name) = path.file_name().and_then(|name| name.to_str()) { if let Some(set) = shells.get_mut(file_name) { set.insert(path); } } } } Ok(shells) } fn get_user_preferred_shell_setting(&self, ctx: &AppContext) -> NewSessionShell { use super::session_settings::SessionSettings; let new_session_shell_override = SessionSettings::as_ref(ctx) .new_session_shell_override .to_owned(); new_session_shell_override.unwrap_or_else(|| { // Fallback logic in case the new_session_shell has not been set. // We attempt to read the legacy setting startup_shell_override, and map it to a // NewSessionShell. If that mapping fails at all, we set it to NewSessionShell::SystemDefault. // We also write back to the new_session_shell_override setting so that we don't have to do this // all the time. self.get_user_preferred_shell_setting_fallback(ctx) }) } fn get_user_preferred_shell_setting_fallback(&self, ctx: &AppContext) -> NewSessionShell { use super::session_settings::SessionSettings; let startup_shell = SessionSettings::as_ref(ctx) .startup_shell_override .to_owned(); match startup_shell { StartupShell::Default => NewSessionShell::SystemDefault, StartupShell::Custom(path) => NewSessionShell::Custom(path), _ => startup_shell .shell_command() .and_then(|command| { for shell in self.shells.iter() { if let Config::KnownLocal(LocalConfig { command: shell_command, executable_path, .. }) = shell.state.as_ref() { if shell_command == command { return Some(NewSessionShell::Executable( executable_path.display().to_string(), )); } } } None }) .unwrap_or(NewSessionShell::SystemDefault), } } /// Finds the first shell that matches the given shell type. pub fn find_known_shell_by_type(&self, shell: ShellType) -> Option { self.shells .iter() .find(|s| matches!(s.state.as_ref(), Config::KnownLocal(LocalConfig { shell_type, .. }) if shell == *shell_type)) .cloned() } /// Finds the first known shell whose command name matches `name`. /// /// This is used to resolve a bare shell name from a tab config (e.g. /// `shell = "pwsh"`) against shells that [`AvailableShells::new`] has /// already discovered. Because that discovery supplements the process /// `PATH` with well-known install locations (such as `/opt/homebrew/bin` /// on macOS), this lookup can find shells that a plain `PATH` search via /// [`AvailableShell::try_from`] would miss when Warp is launched outside /// an interactive shell. /// /// Comparison is case-sensitive on Unix. On Windows, where file names are /// case-insensitive and users commonly omit the `.exe` suffix, comparison /// ignores case and an optional trailing `.exe` on either side. pub fn find_by_command_name(&self, name: &str) -> Option { self.shells .iter() .find(|shell| { let command = match shell.state.as_ref() { Config::KnownLocal(LocalConfig { command, .. }) | Config::MSYS2(LocalConfig { command, .. }) => command.as_str(), Config::Custom(_) | Config::SystemDefault | Config::Wsl { .. } | Config::DockerSandbox { .. } => { return false; } }; command_name_matches(command, name, cfg!(windows)) }) .cloned() } } /// Returns whether two shell command names are equivalent when resolving a /// tab config's `shell` field. See [`AvailableShells::find_by_command_name`]. /// /// `is_windows` selects the matching rules: /// - On Windows, file names are case-insensitive and users commonly omit the /// `.exe` suffix (e.g. `shell = "pwsh"`), so matching is case-insensitive /// and ignores an optional trailing `.exe` on either side. /// - On Unix, matching is a plain case-sensitive equality check. /// /// Parameterized (rather than keying off `cfg!(windows)` internally) so both /// branches can be unit-tested from any host platform. #[cfg(feature = "local_tty")] fn command_name_matches(stored: &str, requested: &str, is_windows: bool) -> bool { if is_windows { let stored_norm = stored.to_ascii_lowercase(); let requested_norm = requested.to_ascii_lowercase(); let stored_stem = stored_norm.strip_suffix(".exe").unwrap_or(&stored_norm); let requested_stem = requested_norm .strip_suffix(".exe") .unwrap_or(&requested_norm); stored_stem == requested_stem } else { stored == requested } } impl Entity for AvailableShells { type Event = (); } impl SingletonEntity for AvailableShells {} #[cfg(feature = "local_tty")] pub fn register(app: &mut impl galaxyui::AddSingletonModel) { #[cfg(windows)] app.add_singleton_model(super::wsl::WslInfo::new); app.add_singleton_model(AvailableShells::new); } #[cfg(test)] #[cfg(not(windows))] #[path = "available_shells_test.rs"] mod tests;