Files
galaxy/app/src/terminal/local_shell/mod.rs
T

328 lines
12 KiB
Rust

use anyhow::{anyhow, Result};
#[cfg(feature = "local_tty")]
use futures::future::{BoxFuture, FutureExt};
use std::{collections::HashMap, path::PathBuf, process::Stdio};
#[cfg(feature = "local_tty")]
use super::model::session::LocalCommandExecutor;
use super::{local_tty::shell::ShellStarter, shell::ShellType};
use crate::terminal::available_shells::AvailableShells;
use crate::terminal::local_tty::shell::ShellStarterSourceOrWslName;
#[cfg(feature = "local_tty")]
use command::r#async::Command;
use warpui::{Entity, ModelContext, SingletonEntity};
#[derive(Debug)]
pub enum LocalShellState {
/// The shell for a session has been loaded.
Loaded(LocalShell),
/// The shell state for a local session has not been loaded.
/// Loading shell information for WSL is extremely expensive and can take upwards of 10s to
/// load.
NotLoaded,
}
/// State of the interactive shell environment capture.
/// This is used for LSP operations that need the full interactive PATH.
#[cfg(feature = "local_tty")]
#[derive(Debug, Default)]
pub enum InteractiveEnvState {
/// Interactive PATH has not been requested yet.
#[default]
NotRequested,
/// Interactive PATH capture is in progress.
/// Stores senders that will be notified when capture completes.
Pending {
waiters: Vec<async_channel::Sender<Option<String>>>,
},
/// Interactive PATH capture completed.
Ready(Option<String>),
}
#[derive(Debug)]
pub struct LocalShell {
shell_type: ShellType,
/// Defines the path of the shell binary, i.e. /bin/zsh
shell_path: PathBuf,
/// The PATH sourced from the user's shell (non-interactive, fast)
path_env_var: Option<String>,
/// The PATH sourced from an interactive login shell (lazy, for LSP)
#[cfg(feature = "local_tty")]
interactive_env_state: InteractiveEnvState,
}
impl LocalShell {
pub fn get_shell_type(&self) -> ShellType {
self.shell_type
}
pub fn get_shell_path(&self) -> &PathBuf {
&self.shell_path
}
pub fn get_path_env_var(&self) -> &Option<String> {
&self.path_env_var
}
}
#[derive(Debug, Clone)]
pub enum LocalShellStateEvent {}
/// Local shell is a singleton model registered on application startup
/// which asynchronously collects information about the user's default
/// shell/corresponding path environment variable. It's useful in
/// executing commands locally without having to initiate a session.
///
/// Its usage pattern can be seen in app/src/external_secrets/mod.rs,
/// where the shell_path is fetched from a caller view (in this case,
/// fetch_secrets in app/src/env_vars/env_var_collection.rs) via a
/// LocalShell handle, and is passed into the external secret manager
/// interface. The interface then dispatches commands to execute via
/// execute_command in this file.
#[cfg(feature = "local_tty")]
impl LocalShellState {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
let preferred_shell = AvailableShells::handle(ctx)
.read(ctx, |shells, ctx| shells.get_user_preferred_shell(ctx));
let shell_starter_source_or_wsl_name = match ShellStarter::init(preferred_shell) {
Some(shell_starter_source_or_wsl_name) => shell_starter_source_or_wsl_name,
None => return LocalShellState::NotLoaded,
};
let shell_starter = match shell_starter_source_or_wsl_name {
ShellStarterSourceOrWslName::Source(starter_source) => match starter_source.into() {
ShellStarter::Direct(starter) | ShellStarter::MSYS2(starter) => starter,
ShellStarter::DockerSandbox(docker_starter) => docker_starter.direct,
ShellStarter::Wsl(_) => return LocalShellState::NotLoaded,
},
// TODO(CORE-3020): Implement WSL for the Local Shell model.
ShellStarterSourceOrWslName::WSLName { .. } => return LocalShellState::NotLoaded,
};
let shell_path = shell_starter.logical_shell_path().to_owned();
let shell_type = shell_starter.shell_type();
let clone = shell_path.clone();
let command = match shell_type {
ShellType::Bash | ShellType::Zsh => "echo $PATH",
ShellType::Fish => "env | grep PATH",
ShellType::PowerShell => "echo $Env:PATH",
};
// Execute a command to fetch the user's PATH var, and store it
// in the associated field
ctx.spawn(
async move { execute_command(shell_type, clone, None, command).await },
|me, res, _| match me {
LocalShellState::Loaded(local_shell_state) => {
// Trim to remove trailing newline from `echo $PATH` output
local_shell_state.path_env_var = res.ok().map(|s| s.trim().to_string());
}
LocalShellState::NotLoaded => {
log::warn!("Tried to execute a command on LocalShell that wasn't loaded")
}
},
);
Self::Loaded(LocalShell {
shell_type,
shell_path,
path_env_var: None,
interactive_env_state: InteractiveEnvState::NotRequested,
})
}
pub fn local_shell_info(&self) -> Option<&LocalShell> {
match self {
LocalShellState::Loaded(shell_state) => Some(shell_state),
LocalShellState::NotLoaded => None,
}
}
/// Returns a future that will yield the PATH from an interactive login shell.
/// This lazily triggers the capture on first call and caches the result.
/// Use this for LSP operations that need the full interactive environment.
pub fn get_interactive_path_env_var(
&mut self,
ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, Option<String>> {
let LocalShellState::Loaded(local_shell) = self else {
// Not loaded - return immediately with None
return futures::future::ready(None).boxed();
};
match &mut local_shell.interactive_env_state {
InteractiveEnvState::Ready(path) => {
// Already captured - return immediately with cached value
futures::future::ready(path.clone()).boxed()
}
InteractiveEnvState::Pending { waiters } => {
// Capture in progress - add a new waiter
let (tx, rx) = async_channel::bounded(1);
waiters.push(tx);
async move { rx.recv().await.ok().flatten() }.boxed()
}
InteractiveEnvState::NotRequested => {
// First request - kick off the capture
let (tx, rx) = async_channel::bounded(1);
local_shell.interactive_env_state =
InteractiveEnvState::Pending { waiters: vec![tx] };
let shell_type = local_shell.shell_type;
let shell_path = local_shell.shell_path.clone();
ctx.spawn(
async move { capture_interactive_shell_env(shell_type, shell_path).await },
move |me, result, _ctx| {
let path = result.ok();
// Notify all waiting receivers
if let LocalShellState::Loaded(local_shell) = me {
if let InteractiveEnvState::Pending { waiters } = std::mem::replace(
&mut local_shell.interactive_env_state,
InteractiveEnvState::Ready(path.clone()),
) {
for waiter in waiters {
let _ = waiter.try_send(path.clone());
}
}
}
},
);
async move { rx.recv().await.ok().flatten() }.boxed()
}
}
}
}
#[cfg(feature = "local_tty")]
pub async fn execute_command(
shell_type: ShellType,
shell_path: PathBuf,
path_env_var: Option<String>,
command: &str,
) -> Result<String> {
let local_command_executor = LocalCommandExecutor::new(Some(shell_path), shell_type);
// Build environment variables map.
// Always include HOME to ensure the shell can expand ~ in rc files - this is critical
// when Warp is launched via launchd (Finder, Dock) with a minimal environment.
let mut env_vars = HashMap::new();
if let Some(home) = dirs::home_dir().and_then(|h| h.to_str().map(|s| s.to_string())) {
env_vars.insert("HOME".to_owned(), home);
}
if let Some(path) = path_env_var {
env_vars.insert("PATH".to_owned(), path);
}
let env_vars = if env_vars.is_empty() {
None
} else {
Some(env_vars)
};
match local_command_executor
.execute_local_command_in_login_shell(command, None, env_vars)
.await
{
Ok(result) => {
let res = result.to_string()?;
if result.success() {
Ok(res)
} else {
Err(anyhow!(res))
}
}
Err(_) => Err(anyhow!("Error while parsing result")),
}
}
/// Captures the PATH environment variable from an interactive login shell.
/// This uses setsid() to start a new session (fully detaching from the terminal)
/// and stdin(null) to prevent interactive prompts from blocking.
#[cfg(feature = "local_tty")]
async fn capture_interactive_shell_env(
shell_type: ShellType,
shell_path: PathBuf,
) -> Result<String> {
let command_str = match shell_type {
ShellType::Bash | ShellType::Zsh => "echo $PATH",
ShellType::Fish => "string join : $PATH",
ShellType::PowerShell => "echo $Env:PATH",
};
// With the `-i` flag, shells may try to set themselves as the foreground process for their
// controlling terminal with `tcsetpgrp` [1]. If the Warp process itself tries to read from
// stdin (for example, some Oz CLI commands have interactive inputs), it may get suspended with
// a `SIGTTIN` or `SIGTTOU` signal.
//
// To prevent this, we run the child in a new session with no controlling terminal by using
// `setsid` [2].
//
// [1]: https://pubs.opengroup.org/onlinepubs/007904975/functions/tcsetpgrp.html
// [2]: https://man7.org/linux/man-pages/man2/setsid.2.html
#[cfg(unix)]
let mut command = Command::new_with_session(&shell_path);
#[cfg(not(unix))]
let mut command = Command::new(&shell_path);
// Add shell-specific flags for interactive login shell
match shell_type {
ShellType::Bash | ShellType::Zsh => {
// -i: interactive (sources .zshrc/.bashrc)
// -l: login shell (sources .zprofile/.bash_profile)
command.args(["-i", "-l", "-c", command_str]);
}
ShellType::Fish => {
command.args(["-i", "-l", "-c", command_str]);
}
ShellType::PowerShell => {
// Note: we intentionally omit `-Login` here. PowerShell 5.1
// (`powershell.exe`) does not support it, and on Windows the
// PATH is managed via system/user environment variables rather
// than login profile scripts, so `-Login` has no practical
// effect even on PowerShell 7.
command.args(["-Command", command_str]);
}
}
// Set HOME to ensure shell can expand ~ in rc files
if let Some(home) = dirs::home_dir() {
command.env("HOME", home);
}
// stdin(null) prevents any prompts from blocking
let output: std::process::Output = command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| anyhow!("Failed to spawn interactive shell: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!(
"[LSP PATH] Interactive shell capture had non-zero exit: {}",
stderr
);
// Still try to use stdout even if exit was non-zero
// (some shells exit non-zero but still produce valid output)
}
let stdout = String::from_utf8_lossy(&output.stdout);
let path = stdout.trim().to_string();
if path.is_empty() {
Err(anyhow!("Interactive shell returned empty PATH"))
} else {
Ok(path)
}
}
impl Entity for LocalShellState {
type Event = LocalShellStateEvent;
}
impl SingletonEntity for LocalShellState {}