diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-30 01:53:55 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-30 01:55:36 +0300 |
| commit | 8df1639048529b383358000879b5ee078a90b9f9 (patch) | |
| tree | 7b0088246c3cc0834ed04cf20bf9726645bdb040 | |
| parent | 817a1562db9156e34f13487344ea147618f9070b (diff) | |
fix: use login shells for macos panes
refs #350
| -rw-r--r-- | docs/next/CHANGELOG.md | 1 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/configuration.mdx | 9 | ||||
| -rw-r--r-- | src/app/api.rs | 4 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 2 | ||||
| -rw-r--r-- | src/app/api/tabs.rs | 2 | ||||
| -rw-r--r-- | src/app/creation.rs | 4 | ||||
| -rw-r--r-- | src/app/input/mod.rs | 2 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 2 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 2 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 2 | ||||
| -rw-r--r-- | src/app/mod.rs | 46 | ||||
| -rw-r--r-- | src/app/state.rs | 2 | ||||
| -rw-r--r-- | src/config.rs | 3 | ||||
| -rw-r--r-- | src/config/model.rs | 14 | ||||
| -rw-r--r-- | src/main.rs | 4 | ||||
| -rw-r--r-- | src/pane.rs | 231 | ||||
| -rw-r--r-- | src/persist/restore.rs | 26 | ||||
| -rw-r--r-- | src/server/notifications.rs | 2 | ||||
| -rw-r--r-- | src/terminal/runtime.rs | 8 | ||||
| -rw-r--r-- | src/ui/mobile.rs | 2 | ||||
| -rw-r--r-- | src/ui/sidebar.rs | 2 | ||||
| -rw-r--r-- | src/workspace.rs | 32 | ||||
| -rw-r--r-- | src/workspace/tab.rs | 22 |
23 files changed, 362 insertions, 62 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 20f39658..ec56a4b5 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - The OpenCode integration no longer treats `session.created` or `session.updated` plugin events as idle signals, so active sessions stay marked working until OpenCode reports `session.status` or `session.idle`. (#351) +- New interactive panes now use login-shell startup on macOS by default so Homebrew and other login PATH setup is available, with `terminal.shell_mode = "non_login"` as an opt-out. (#350) ## [0.6.5] - 2026-05-29 diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 936cc3ac..48a5a321 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -56,6 +56,15 @@ default_shell = "nu" When unset or empty, Herdr uses `$SHELL`, then `/bin/sh`. This is an executable name or path, not a shell command line. Existing panes keep their current shell until they are recreated. Command panes still run through `/bin/sh -c`; detached custom command keybindings use Herdr's existing `/bin/sh -lc` path. +Set how Herdr starts newly created interactive pane shells: + +```toml +[terminal] +shell_mode = "auto" +``` + +`shell_mode = "auto"` starts login shells on macOS so login-only PATH setup such as `/usr/libexec/path_helper` and Homebrew shell initialization runs in new panes. On other platforms, it keeps the existing non-login shell behavior. Use `"login"` to force login-shell startup, or `"non_login"` to opt out. Command panes, detached custom command keybindings, and explicit argv launches keep their existing command execution paths. + Set the working directory policy for new panes, tabs, and workspaces: ```toml diff --git a/src/app/api.rs b/src/app/api.rs index df20cd34..a26b2856 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -311,7 +311,7 @@ impl App { cwd, self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, - &self.state.default_shell, + crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), self.event_tx.clone(), self.render_notify.clone(), self.render_dirty.clone(), @@ -644,7 +644,7 @@ mod tests { live_cwd.clone(), 0, crate::terminal_theme::TerminalTheme::default(), - "/bin/sh", + crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), events, std::sync::Arc::new(tokio::sync::Notify::new()), std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 4620329f..67bac8d3 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -50,7 +50,7 @@ impl App { split_cwd, scrollback_limit_bytes, host_terminal_theme, - &default_shell, + crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode), params.focus, ) { Some(Ok(result)) => result, diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs index f605d143..4057eced 100644 --- a/src/app/api/tabs.rs +++ b/src/app/api/tabs.rs @@ -86,7 +86,7 @@ impl App { cwd, scrollback_limit_bytes, host_terminal_theme, - &default_shell, + crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode), ) }); match result { diff --git a/src/app/creation.rs b/src/app/creation.rs index 0f63d60d..f8edd676 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -110,7 +110,7 @@ impl App { initial_cwd, self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, - &self.state.default_shell, + crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), )?; let root_pane = ws.tabs[idx].root_pane; self.terminal_runtimes.insert(terminal.id.clone(), runtime); @@ -142,7 +142,7 @@ impl App { cols, self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, - &self.state.default_shell, + crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), self.event_tx.clone(), self.render_notify.clone(), self.render_dirty.clone(), diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 76a32fc1..cfcc3039 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -417,7 +417,7 @@ impl AppState { cwd, self.pane_scrollback_limit_bytes, self.host_terminal_theme, - &self.default_shell, + crate::pane::PaneShellConfig::new(&self.default_shell, self.shell_mode), ) { let new_id = new_pane.pane_id; terminal_runtimes.insert(new_pane.terminal.id.clone(), new_pane.runtime); diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index a9badfa9..e3ad0bc5 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -1975,7 +1975,7 @@ mod tests { 80, app.state.pane_scrollback_limit_bytes, app.state.host_terminal_theme, - &app.state.default_shell, + crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode), app.event_tx.clone(), app.render_notify.clone(), app.render_dirty.clone(), diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index e7581c5b..6a7e8d7c 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -1820,7 +1820,7 @@ last_pane = "prefix+tab" 80, app.state.pane_scrollback_limit_bytes, app.state.host_terminal_theme, - &app.state.default_shell, + crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode), app.event_tx.clone(), app.render_notify.clone(), app.render_dirty.clone(), diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 0a35c3be..5447e2b0 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -845,7 +845,7 @@ mod tests { 80, app.state.pane_scrollback_limit_bytes, app.state.host_terminal_theme, - &app.state.default_shell, + crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode), app.event_tx.clone(), app.render_notify.clone(), app.render_dirty.clone(), diff --git a/src/app/mod.rs b/src/app/mod.rs index 1291d2a0..d8071c4d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -289,6 +289,7 @@ impl App { 80, config.advanced.scrollback_limit_bytes, &config.terminal.default_shell, + config.terminal.shell_mode, config.session.resume_agents_on_restore, event_tx.clone(), render_notify.clone(), @@ -499,6 +500,7 @@ impl App { cjk_ime_cursor_shape: config.experimental.cjk_ime_cursor_shape.to_decscusr(), kitty_graphics_enabled: config.experimental.kitty_graphics, default_shell: config.terminal.default_shell.clone(), + shell_mode: config.terminal.shell_mode, new_terminal_cwd: config.terminal.new_cwd.clone(), pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes, accent: crate::config::parse_color(&config.ui.accent), @@ -606,6 +608,7 @@ impl App { snapshot, config.advanced.scrollback_limit_bytes, &config.terminal.default_shell, + config.terminal.shell_mode, imports, app.event_tx.clone(), app.render_notify.clone(), @@ -1143,6 +1146,7 @@ impl App { if !invalid_section("terminal") { self.state.default_shell = config.terminal.default_shell.clone(); + self.state.shell_mode = config.terminal.shell_mode; self.state.new_terminal_cwd = config.terminal.new_cwd.clone(); } @@ -1707,7 +1711,7 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( &path, - "[terminal]\ndefault_shell = \"nu\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\nredraw_on_focus_gained = false\n[ui.toast]\ndelivery = \"herdr\"\n", + "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\nredraw_on_focus_gained = false\n[ui.toast]\ndelivery = \"herdr\"\n", ) .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); @@ -1735,6 +1739,10 @@ mod tests { assert!(app.state.request_client_config_reload); assert_eq!(app.state.default_shell, "nu"); assert_eq!( + app.state.shell_mode, + crate::config::ShellModeConfig::NonLogin + ); + assert_eq!( app.state.new_terminal_cwd, crate::config::NewTerminalCwdConfig::Home ); @@ -1996,6 +2004,42 @@ mod tests { } #[test] + fn reload_config_preserves_invalid_terminal_section_but_applies_valid_ui() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("reload-config-invalid-terminal-section"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"sideways\"\nnew_cwd = \"home\"\n[ui.toast]\ndelivery = \"terminal\"\n", + ) + .unwrap(); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + + let mut app = test_app(); + let original_default_shell = app.state.default_shell.clone(); + let original_shell_mode = app.state.shell_mode; + let original_new_cwd = app.state.new_terminal_cwd.clone(); + let report = app.reload_config(); + + assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); + assert_eq!(app.state.default_shell, original_default_shell); + assert_eq!(app.state.shell_mode, original_shell_mode); + assert_eq!(app.state.new_terminal_cwd, original_new_cwd); + assert_eq!( + app.state.toast_config.delivery, + crate::config::ToastDelivery::Terminal + ); + assert!(app + .state + .config_diagnostic + .as_deref() + .is_some_and(|message| message.contains("invalid terminal config"))); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] fn settings_save_toast_delivery_persists_then_applies_live_config() { let _guard = config_env_lock().lock().unwrap(); let path = temp_config_path("settings-save-toast-delivery"); diff --git a/src/app/state.rs b/src/app/state.rs index 012d79e4..a9c1b8a3 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1157,6 +1157,7 @@ pub struct AppState { pub cjk_ime_cursor_shape: u8, pub kitty_graphics_enabled: bool, pub default_shell: String, + pub shell_mode: crate::config::ShellModeConfig, pub new_terminal_cwd: NewTerminalCwdConfig, pub pane_scrollback_limit_bytes: usize, #[allow(dead_code)] // kept for backward compat; palette.accent is the source of truth @@ -1461,6 +1462,7 @@ impl AppState { cjk_ime_cursor_shape: 2, // steady_block kitty_graphics_enabled: false, default_shell: String::new(), + shell_mode: crate::config::ShellModeConfig::Auto, new_terminal_cwd: NewTerminalCwdConfig::Follow, pane_scrollback_limit_bytes: crate::config::DEFAULT_SCROLLBACK_LIMIT_BYTES, accent: Color::Cyan, diff --git a/src/config.rs b/src/config.rs index b4fcf1ef..ab194cf5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,7 +19,8 @@ pub use self::{ }, model::{ validated_sidebar_bounds, AgentPanelScopeConfig, Config, ConfigReloadReport, - ConfigReloadStatus, KeysConfig, NewTerminalCwdConfig, ToastConfig, ToastDelivery, + ConfigReloadStatus, KeysConfig, NewTerminalCwdConfig, ShellModeConfig, ToastConfig, + ToastDelivery, }, sound::SoundConfig, theme::{parse_color, CustomThemeColors, ThemeConfig}, diff --git a/src/config/model.rs b/src/config/model.rs index 84d03bc2..77af2f75 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -63,11 +63,22 @@ impl<'de> Deserialize<'de> for NewTerminalCwdConfig { } } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ShellModeConfig { + #[default] + Auto, + Login, + NonLogin, +} + #[derive(Debug, Default, Deserialize)] #[serde(default)] pub struct TerminalConfig { /// Executable used for new interactive panes. Empty means SHELL, then /bin/sh. pub default_shell: String, + /// Startup mode for new interactive pane shells. + pub shell_mode: ShellModeConfig, /// CWD policy for new interactive panes, tabs, and workspaces. pub new_cwd: NewTerminalCwdConfig, } @@ -494,13 +505,16 @@ mod tests { fn terminal_default_shell_defaults_empty_and_parses() { let default_config = Config::default(); assert!(default_config.terminal.default_shell.is_empty()); + assert_eq!(default_config.terminal.shell_mode, ShellModeConfig::Auto); let toml = r#" [terminal] default_shell = "nu" +shell_mode = "non_login" "#; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.terminal.default_shell, "nu"); + assert_eq!(config.terminal.shell_mode, ShellModeConfig::NonLogin); } #[test] diff --git a/src/main.rs b/src/main.rs index 163ce66e..a3d19876 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,6 +84,10 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Empty means $SHELL, then /bin/sh. # default_shell = "" +# Startup mode for new interactive pane shells: "auto", "login", or "non_login". +# "auto" uses login shells on macOS and keeps the current behavior elsewhere. +# shell_mode = "auto" + # CWD policy for new panes, tabs, and workspaces when no explicit --cwd is provided. # Use "follow" to inherit the source pane/workspace, "home" for $HOME, # "current" for Herdr's process directory, or a fixed path such as "~/Projects". diff --git a/src/pane.rs b/src/pane.rs index 885fb11d..97aeb9cb 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1,5 +1,6 @@ use std::cell::Cell; -use std::io::{Read, Write}; +use std::io::{self, Read, Write}; +use std::path::Path; use std::sync::{ atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering}, Arc, Mutex, @@ -637,6 +638,96 @@ fn pane_shell_from(configured_shell: &str, env_shell: Option<String>) -> String .unwrap_or_else(|| "/bin/sh".into()) } +#[derive(Clone, Copy)] +pub(crate) struct PaneShellConfig<'a> { + pub(crate) default_shell: &'a str, + pub(crate) mode: crate::config::ShellModeConfig, +} + +impl<'a> PaneShellConfig<'a> { + pub(crate) fn new(default_shell: &'a str, mode: crate::config::ShellModeConfig) -> Self { + Self { + default_shell, + mode, + } + } +} + +fn shell_mode_uses_login_shell( + mode: crate::config::ShellModeConfig, + target_is_macos: bool, +) -> bool { + match mode { + crate::config::ShellModeConfig::Auto => target_is_macos, + crate::config::ShellModeConfig::Login => true, + crate::config::ShellModeConfig::NonLogin => false, + } +} + +fn is_executable_file(path: &Path) -> bool { + let Ok(metadata) = path.metadata() else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +fn resolve_shell_for_login_mode(shell: &str) -> io::Result<String> { + if shell.contains(std::path::MAIN_SEPARATOR) { + let path = Path::new(shell); + return is_executable_file(path) + .then(|| shell.to_string()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("login shell {shell:?} is not executable"), + ) + }); + } + + std::env::var_os("PATH") + .and_then(|path| { + std::env::split_paths(&path) + .map(|dir| dir.join(shell)) + .find(|candidate| is_executable_file(candidate)) + }) + .and_then(|path| path.into_os_string().into_string().ok()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("login shell {shell:?} was not found on PATH"), + ) + }) +} + +fn pane_shell_command_builder_for_target( + shell_config: PaneShellConfig<'_>, + target_is_macos: bool, +) -> io::Result<CommandBuilder> { + let shell = pane_shell(shell_config.default_shell); + if shell_mode_uses_login_shell(shell_config.mode, target_is_macos) { + let mut cmd = CommandBuilder::new_default_prog(); + cmd.env("SHELL", resolve_shell_for_login_mode(&shell)?); + Ok(cmd) + } else { + Ok(CommandBuilder::new(&shell)) + } +} + +fn pane_shell_command_builder(shell_config: PaneShellConfig<'_>) -> io::Result<CommandBuilder> { + pane_shell_command_builder_for_target(shell_config, cfg!(target_os = "macos")) +} + #[cfg(unix)] fn duplicate_fd(fd: std::os::fd::RawFd) -> std::io::Result<std::os::fd::RawFd> { let duplicated = unsafe { libc::dup(fd) }; @@ -909,7 +1000,7 @@ impl PaneRuntime { cwd: std::path::PathBuf, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - default_shell: &str, + shell_config: PaneShellConfig<'_>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, @@ -921,7 +1012,7 @@ impl PaneRuntime { cwd, scrollback_limit_bytes, host_terminal_theme, - default_shell, + shell_config, None, events, render_notify, @@ -936,14 +1027,13 @@ impl PaneRuntime { cwd: std::path::PathBuf, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - default_shell: &str, + shell_config: PaneShellConfig<'_>, initial_history_ansi: Option<&str>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, ) -> std::io::Result<Self> { - let shell = pane_shell(default_shell); - let mut cmd = CommandBuilder::new(&shell); + let mut cmd = pane_shell_command_builder(shell_config)?; cmd.cwd(cwd); cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE); apply_pane_terminal_env(&mut cmd); @@ -2294,6 +2384,135 @@ mod tests { } #[test] + fn shell_mode_auto_uses_login_shell_only_on_macos() { + assert!(shell_mode_uses_login_shell( + crate::config::ShellModeConfig::Auto, + true + )); + assert!(!shell_mode_uses_login_shell( + crate::config::ShellModeConfig::Auto, + false + )); + assert!(shell_mode_uses_login_shell( + crate::config::ShellModeConfig::Login, + false + )); + assert!(!shell_mode_uses_login_shell( + crate::config::ShellModeConfig::NonLogin, + true + )); + } + + #[test] + fn login_shell_builder_uses_default_prog_with_resolved_shell_env() { + let cmd = pane_shell_command_builder_for_target( + PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::Login), + false, + ) + .unwrap(); + assert!(cmd.is_default_prog()); + assert_eq!( + cmd.get_env("SHELL").and_then(std::ffi::OsStr::to_str), + Some("/bin/sh") + ); + } + + #[test] + fn auto_shell_builder_uses_login_shell_on_macos_target() { + let cmd = pane_shell_command_builder_for_target( + PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::Auto), + true, + ) + .unwrap(); + assert!(cmd.is_default_prog()); + assert_eq!( + cmd.get_env("SHELL").and_then(std::ffi::OsStr::to_str), + Some("/bin/sh") + ); + } + + #[test] + fn auto_shell_builder_keeps_direct_shell_on_non_macos_target() { + let cmd = pane_shell_command_builder_for_target( + PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::Auto), + false, + ) + .unwrap(); + assert!(!cmd.is_default_prog()); + assert_eq!(cmd.get_argv(), &[std::ffi::OsString::from("/bin/sh")]); + } + + #[test] + fn login_shell_builder_rejects_missing_shell_instead_of_falling_back() { + let err = pane_shell_command_builder_for_target( + PaneShellConfig::new( + "/__herdr_missing_shell__", + crate::config::ShellModeConfig::Login, + ), + false, + ) + .unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn login_shell_builder_resolves_bare_shell_names_from_path() { + let _lock = crate::integration::integration_env_lock(); + let base = std::env::temp_dir().join(format!( + "herdr-login-shell-path-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let bin = base.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let shell = bin.join("fake-shell"); + std::fs::write(&shell, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let original_path = std::env::var_os("PATH"); + std::env::set_var("PATH", &bin); + + let cmd = pane_shell_command_builder_for_target( + PaneShellConfig::new("fake-shell", crate::config::ShellModeConfig::Login), + false, + ) + .unwrap(); + + assert!(cmd.is_default_prog()); + assert_eq!( + cmd.get_env("SHELL").and_then(std::ffi::OsStr::to_str), + shell.to_str() + ); + match original_path { + Some(path) => std::env::set_var("PATH", path), + None => std::env::remove_var("PATH"), + } + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn login_shell_resolution_preserves_shell_paths() { + assert_eq!(resolve_shell_for_login_mode("/bin/sh").unwrap(), "/bin/sh"); + } + + #[test] + fn non_login_shell_builder_execs_resolved_shell_directly() { + let cmd = pane_shell_command_builder(PaneShellConfig::new( + "/bin/sh", + crate::config::ShellModeConfig::NonLogin, + )) + .unwrap(); + assert!(!cmd.is_default_prog()); + assert_eq!(cmd.get_argv(), &[std::ffi::OsString::from("/bin/sh")]); + } + + #[test] fn pane_terminal_identity_overrides_outer_terminal_env() { let output = capture_shell_output("printf '%s\\n%s\\n' \"$TERM\" \"$COLORTERM\"", &[]); assert_eq!(output, "xterm-256color\ntruecolor\n"); diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 916478b6..881fca6c 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -36,7 +36,7 @@ struct PaneRestoreStartup<'a> { struct RestoreRuntimeContext<'a> { scrollback_limit_bytes: usize, - default_shell: &'a str, + shell_config: crate::pane::PaneShellConfig<'a>, resume_agents_on_restore: bool, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, @@ -68,6 +68,7 @@ pub fn restore( cols: u16, scrollback_limit_bytes: usize, default_shell: &str, + shell_mode: crate::config::ShellModeConfig, resume_agents_on_restore: bool, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, @@ -80,7 +81,7 @@ pub fn restore( rows, cols, scrollback_limit_bytes, - default_shell, + crate::pane::PaneShellConfig::new(default_shell, shell_mode), resume_agents_on_restore, &mut imported_panes, events, @@ -94,6 +95,7 @@ pub fn restore_handoff( snapshot: &SessionSnapshot, scrollback_limit_bytes: usize, default_shell: &str, + shell_mode: crate::config::ShellModeConfig, imports: &mut HashMap<u32, crate::handoff_runtime::ImportedHandoffRuntime>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, @@ -105,7 +107,7 @@ pub fn restore_handoff( 24, 80, scrollback_limit_bytes, - default_shell, + crate::pane::PaneShellConfig::new(default_shell, shell_mode), false, imports, events, @@ -159,7 +161,7 @@ fn restore_with_imports_strict( rows: u16, cols: u16, scrollback_limit_bytes: usize, - default_shell: &str, + shell_config: crate::pane::PaneShellConfig<'_>, resume_agents_on_restore: bool, imported_panes: &mut HashMap<u32, crate::handoff_runtime::ImportedHandoffRuntime>, events: mpsc::Sender<AppEvent>, @@ -172,7 +174,7 @@ fn restore_with_imports_strict( rows, cols, scrollback_limit_bytes, - default_shell, + shell_config, resume_agents_on_restore, imported_panes, events, @@ -199,7 +201,7 @@ fn restore_with_imports( rows: u16, cols: u16, scrollback_limit_bytes: usize, - default_shell: &str, + shell_config: crate::pane::PaneShellConfig<'_>, resume_agents_on_restore: bool, imported_panes: &mut HashMap<u32, crate::handoff_runtime::ImportedHandoffRuntime>, events: mpsc::Sender<AppEvent>, @@ -212,7 +214,7 @@ fn restore_with_imports( rows, cols, scrollback_limit_bytes, - default_shell, + shell_config, resume_agents_on_restore, imported_panes, events, @@ -228,7 +230,7 @@ fn restore_with_imports_and_failures( rows: u16, cols: u16, scrollback_limit_bytes: usize, - default_shell: &str, + shell_config: crate::pane::PaneShellConfig<'_>, resume_agents_on_restore: bool, imported_panes: &mut HashMap<u32, crate::handoff_runtime::ImportedHandoffRuntime>, events: mpsc::Sender<AppEvent>, @@ -243,7 +245,7 @@ fn restore_with_imports_and_failures( for (idx, ws_snap) in snapshot.workspaces.iter().enumerate() { let runtime_context = RestoreRuntimeContext { scrollback_limit_bytes, - default_shell, + shell_config, resume_agents_on_restore, events: events.clone(), render_notify: render_notify.clone(), @@ -454,7 +456,7 @@ fn restore_tab( cwd.clone(), runtime_context.scrollback_limit_bytes, crate::terminal_theme::TerminalTheme::default(), - runtime_context.default_shell, + runtime_context.shell_config, startup.initial_history_ansi, runtime_context.events.clone(), runtime_context.render_notify.clone(), @@ -1053,6 +1055,7 @@ mod tests { 80, 0, "/bin/true", + crate::config::ShellModeConfig::NonLogin, false, events, Arc::new(Notify::new()), @@ -1152,6 +1155,7 @@ mod tests { 80, 0, "/bin/sh", + crate::config::ShellModeConfig::NonLogin, true, events, Arc::new(Notify::new()), @@ -1187,6 +1191,7 @@ mod tests { 40, 4096, "/bin/sh", + crate::config::ShellModeConfig::NonLogin, false, events, render_notify, @@ -1225,6 +1230,7 @@ mod tests { 40, 4096, "/bin/sh", + crate::config::ShellModeConfig::NonLogin, false, events, render_notify, diff --git a/src/server/notifications.rs b/src/server/notifications.rs index 0ed681ac..f0b2d746 100644 --- a/src/server/notifications.rs +++ b/src/server/notifications.rs @@ -113,7 +113,7 @@ mod tests { live_cwd.clone(), 0, crate::terminal_theme::TerminalTheme::default(), - "/bin/sh", + crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), events, std::sync::Arc::new(tokio::sync::Notify::new()), std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index b1a25ee9..c7bb8810 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -84,7 +84,7 @@ impl TerminalRuntime { cwd: std::path::PathBuf, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - default_shell: &str, + shell_config: crate::pane::PaneShellConfig<'_>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, @@ -96,7 +96,7 @@ impl TerminalRuntime { cwd, scrollback_limit_bytes, host_terminal_theme, - default_shell, + shell_config, events, render_notify, render_dirty, @@ -111,7 +111,7 @@ impl TerminalRuntime { cwd: std::path::PathBuf, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - default_shell: &str, + shell_config: crate::pane::PaneShellConfig<'_>, initial_history_ansi: Option<&str>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, @@ -124,7 +124,7 @@ impl TerminalRuntime { cwd, scrollback_limit_bytes, host_terminal_theme, - default_shell, + shell_config, initial_history_ansi, events, render_notify, diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs index c1dd57b6..9b400f51 100644 --- a/src/ui/mobile.rs +++ b/src/ui/mobile.rs @@ -1005,7 +1005,7 @@ mod tests { live_cwd.clone(), 0, crate::terminal_theme::TerminalTheme::default(), - "/bin/sh", + crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), events, std::sync::Arc::new(tokio::sync::Notify::new()), std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 4c68aeb4..692e0236 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -1286,7 +1286,7 @@ mod tests { live_cwd.clone(), 0, crate::terminal_theme::TerminalTheme::default(), - "/bin/sh", + crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), events, std::sync::Arc::new(tokio::sync::Notify::new()), std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), diff --git a/src/workspace.rs b/src/workspace.rs index a302f3f8..4bab61a7 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -103,7 +103,7 @@ impl Workspace { cols: u16, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - default_shell: &str, + shell_config: crate::pane::PaneShellConfig<'_>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, @@ -114,7 +114,7 @@ impl Workspace { cols, scrollback_limit_bytes, host_terminal_theme, - default_shell, + shell_config, events, render_notify, render_dirty, @@ -139,7 +139,7 @@ impl Workspace { cols, scrollback_limit_bytes, host_terminal_theme, - "", + crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), events, render_notify, render_dirty, @@ -154,7 +154,7 @@ impl Workspace { cols: u16, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - default_shell: &str, + shell_config: crate::pane::PaneShellConfig<'_>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, @@ -181,7 +181,7 @@ impl Workspace { cols, scrollback_limit_bytes, host_terminal_theme, - default_shell, + shell_config, events, render_notify, render_dirty, @@ -244,7 +244,7 @@ impl Workspace { cwd: PathBuf, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - default_shell: &str, + shell_config: crate::pane::PaneShellConfig<'_>, ) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> { self.create_tab_with_runtime( rows, @@ -252,7 +252,7 @@ impl Workspace { cwd, scrollback_limit_bytes, host_terminal_theme, - default_shell, + shell_config, None, ) } @@ -264,7 +264,7 @@ impl Workspace { cwd: PathBuf, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - default_shell: &str, + shell_config: crate::pane::PaneShellConfig<'_>, argv: Option<&[String]>, ) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> { let number = self.tabs.len() + 1; @@ -302,7 +302,7 @@ impl Workspace { cols, scrollback_limit_bytes, host_terminal_theme, - default_shell, + shell_config, events, render_notify, render_dirty, @@ -368,7 +368,7 @@ impl Workspace { cwd: Option<PathBuf>, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - default_shell: &str, + shell_config: crate::pane::PaneShellConfig<'_>, ) -> std::io::Result<crate::workspace::tab::NewPane> { let new_pane = self .active_tab_mut() @@ -380,7 +380,7 @@ impl Workspace { cwd, scrollback_limit_bytes, host_terminal_theme, - default_shell, + shell_config, )?; self.register_new_pane(new_pane.pane_id); Ok(new_pane) @@ -395,7 +395,7 @@ impl Workspace { cwd: Option<PathBuf>, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - default_shell: &str, + shell_config: crate::pane::PaneShellConfig<'_>, focus_new_pane: bool, ) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> { self.split_pane_with_runtime( @@ -406,7 +406,7 @@ impl Workspace { cwd, scrollback_limit_bytes, host_terminal_theme, - default_shell, + shell_config, focus_new_pane, None, ) @@ -433,7 +433,7 @@ impl Workspace { cwd, scrollback_limit_bytes, host_terminal_theme, - "", + crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), focus_new_pane, Some(argv), ) @@ -449,7 +449,7 @@ impl Workspace { cwd: Option<PathBuf>, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - default_shell: &str, + shell_config: crate::pane::PaneShellConfig<'_>, focus_new_pane: bool, argv: Option<&[String]>, ) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> { @@ -475,7 +475,7 @@ impl Workspace { cwd, scrollback_limit_bytes, host_terminal_theme, - default_shell, + shell_config, ) } { Ok(new_pane) => new_pane, diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs index 100e67d0..f3ad594e 100644 --- a/src/workspace/tab.rs +++ b/src/workspace/tab.rs @@ -53,7 +53,7 @@ impl Tab { cols: u16, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - default_shell: &str, + shell_config: crate::pane::PaneShellConfig<'_>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, @@ -65,7 +65,7 @@ impl Tab { cols, scrollback_limit_bytes, host_terminal_theme, - default_shell, + shell_config, events, render_notify, render_dirty, @@ -92,7 +92,7 @@ impl Tab { cols, scrollback_limit_bytes, host_terminal_theme, - "", + crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), events, render_notify, render_dirty, @@ -108,7 +108,7 @@ impl Tab { cols: u16, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - default_shell: &str, + shell_config: crate::pane::PaneShellConfig<'_>, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, @@ -136,7 +136,7 @@ impl Tab { initial_cwd.clone(), scrollback_limit_bytes, host_terminal_theme, - default_shell, + shell_config, events.clone(), render_notify.clone(), render_dirty.clone(), @@ -194,7 +194,7 @@ impl Tab { cwd: Option<PathBuf>, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - default_shell: &str, + shell_config: crate::pane::PaneShellConfig<'_>, ) -> std::io::Result<NewPane> { self.split_focused_with_runtime( direction, @@ -203,7 +203,7 @@ impl Tab { cwd, scrollback_limit_bytes, host_terminal_theme, - default_shell, + shell_config, None, ) } @@ -226,7 +226,7 @@ impl Tab { cwd, scrollback_limit_bytes, host_terminal_theme, - "", + crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), Some(SplitCommand::Shell { command, extra_env }), ) } @@ -248,7 +248,7 @@ impl Tab { cwd, scrollback_limit_bytes, host_terminal_theme, - "", + crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), Some(SplitCommand::Argv { argv }), ) } @@ -261,7 +261,7 @@ impl Tab { cwd: Option<PathBuf>, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, - default_shell: &str, + shell_config: crate::pane::PaneShellConfig<'_>, command: Option<SplitCommand<'_>>, ) -> std::io::Result<NewPane> { let previous_focus = self.layout.focused(); @@ -308,7 +308,7 @@ impl Tab { actual_cwd.clone(), scrollback_limit_bytes, host_terminal_theme, - default_shell, + shell_config, self.events.clone(), self.render_notify.clone(), self.render_dirty.clone(), |
