Files
galaxy/app/src/terminal/available_shells.rs
T

995 lines
38 KiB
Rust

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<StartupShell> for LocalConfig {
type Error = ();
#[cfg(feature = "local_tty")]
fn try_from(value: StartupShell) -> Result<Self, Self::Error> {
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<Self, Self::Error> {
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 <image>`). `None` means "use sbx's default
/// image".
base_image: Option<String>,
},
}
// 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<AvailableShell>` 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<String>,
state: Arc<Config>,
}
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<String> {
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<String> {
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<ShellLaunchData>`] |
/// |:---------------------------|:----------:|-----------------------------------:|
/// | [`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<ShellLaunchData> {
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<String>) -> 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<AvailableShell> 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<AvailableShell> 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<AvailableShell> 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<Self, Self::Error> {
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<NewSessionShell> for AvailableShell {
type Error = ();
fn try_from(value: NewSessionShell) -> Result<Self, Self::Error> {
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<AvailableShell>,
/// A map of shell name to the number of times it appears in the list.
#[cfg(feature = "local_tty")]
shell_counts: HashMap<String, usize>,
}
#[cfg(not(feature = "local_tty"))]
impl AvailableShells {
pub fn get_available_shells(&self) -> impl Iterator<Item = &AvailableShell> {
std::iter::empty()
}
pub fn get_from_shell_launch_data(&self, _config: &ShellLaunchData) -> Option<AvailableShell> {
None
}
}
#[cfg(feature = "local_tty")]
impl AvailableShells {
pub fn new(_ctx: &mut ModelContext<Self>) -> 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::<Vec<PathBuf>>();
// 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<Item = &AvailableShell> {
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::<str>(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<AvailableShell> {
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<Self>,
) -> 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<AvailableShell> {
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<PathBuf> {
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<Item = &'a PathBuf>,
) -> Vec<PathBuf> {
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<HashMap<String, HashSet<PathBuf>>> {
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<AvailableShell> {
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<AvailableShell> {
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;