From f9b8f3b175b2dc67579647a37c2fd78efaeb539c Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Wed, 8 Jul 2026 13:28:13 +0800 Subject: [PATCH] fix(shell): detect PATH-installed shells missing from /etc/shells (#23) The Unix shell picker only enumerated $SHELL and /etc/shells, but Homebrew and nix don't register what they install there (Homebrew only suggests adding fish to /etc/shells, and few users do). A brew-installed fish therefore never appeared in the "+" dropdown, even though the app can spawn and shell-integrate it fine once selected. Probe a curated set of well-known shells (fish, nu, pwsh, elvish, xonsh) on PATH as a catch-all, after the /etc/shells entries. Registered shells keep their /etc/shells paths via the existing basename dedupe; only the unregistered leftovers are picked up from PATH. Uses the login-shell- enriched PATH so Dock launches see Homebrew's prefix too. Closes #18 Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- CHANGELOG.md | 8 +++-- src/core/shells.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ae99861..eb6217bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - The "+" button now opens a shell picker: tty7 detects the shells installed - on this machine (the login shell plus `/etc/shells` on Unix; PowerShell 7, - Windows PowerShell, Command Prompt, Git Bash and WSL distributions on - Windows) and lists them in a dropdown, so opening a tab in a different shell + on this machine (on Unix the login shell, `/etc/shells`, plus well-known + shells found on `PATH` — fish, nushell, pwsh and friends installed by + Homebrew/nix are never registered in `/etc/shells`; on Windows PowerShell 7, + Windows PowerShell, Command Prompt, Git Bash and WSL distributions) + and lists them in a dropdown, so opening a tab in a different shell no longer requires editing `config.json`. The default entry leads the menu, ⌘T / Ctrl+T still opens a default tab in one keystroke, and splitting a pane inherits its shell — a fish tab splits into more fish, not back to the diff --git a/src/core/shells.rs b/src/core/shells.rs index a29ba6e6..2040cad4 100644 --- a/src/core/shells.rs +++ b/src/core/shells.rs @@ -8,7 +8,10 @@ //! - **Unix**: `/etc/shells` is the system's own inventory — parse it, keep the //! entries that exist, dedupe by basename (the same shell often appears as //! both `/bin/zsh` and `/usr/local/bin/zsh`). The login shell (`$SHELL`) is -//! seeded first so it wins its dedupe slot and leads the list. +//! seeded first so it wins its dedupe slot and leads the list. Package +//! managers don't register what they install there (Homebrew only *suggests* +//! adding fish to `/etc/shells`), so a curated set of well-known shells is +//! then probed on `PATH` as the catch-all. //! - **Windows**: there is no inventory file, so probe each shell's known //! homes: PowerShell 7 across its six-ish install roots, Windows PowerShell //! in System32, cmd via `%ComSpec%`, Git Bash under the Git install, and WSL @@ -133,14 +136,47 @@ fn unix_shells_from( out } +/// Shells package managers commonly install *without* registering them in +/// `/etc/shells` — Homebrew and nix leave that edit to the user, and few make +/// it, so `/etc/shells` misses e.g. a brew-installed fish entirely. Probed on +/// `PATH` (the login-shell-enriched one — see `enrich_path_from_login_shell` +/// in `main` — so Dock launches see Homebrew's prefix too). +#[cfg_attr(windows, allow(dead_code))] +const PATH_PROBED_SHELLS: [&str; 5] = ["fish", "nu", "pwsh", "elvish", "xonsh"]; + +/// Expand [`PATH_PROBED_SHELLS`] into concrete candidate paths, one per +/// `path_var` directory in `PATH` order. Fed through the same exists + dedupe +/// pass as the `/etc/shells` entries, so the first directory that actually +/// holds the shell wins — `which` semantics without spawning anything. +/// Relative `PATH` entries are skipped: a `./fish` candidate would resolve +/// somewhere else at every spawn. Pure — the caller supplies `path_var`. +#[cfg_attr(windows, allow(dead_code))] +fn path_shell_candidates(path_var: &str) -> Vec { + let dirs: Vec<&str> = path_var.split(':').filter(|d| d.starts_with('/')).collect(); + PATH_PROBED_SHELLS + .iter() + .flat_map(|name| { + dirs.iter() + .map(move |dir| format!("{}/{name}", dir.trim_end_matches('/'))) + }) + .collect() +} + #[cfg(unix)] fn detect_unix() -> Vec { // Seed the login shell first so it wins its basename's dedupe slot and // leads the list — it also covers shells installed outside /etc/shells // (nix/homebrew installs the user pointed $SHELL at without registering). + // The PATH probe comes last: registered shells keep their `/etc/shells` + // paths, and only the unregistered leftovers (brew fish, nushell, …) are + // picked up from `PATH`. let login = std::env::var("SHELL").ok().filter(|s| !s.is_empty()); let etc = std::fs::read_to_string("/etc/shells").unwrap_or_default(); - let candidates = login.into_iter().chain(parse_etc_shells(&etc)); + let path_var = std::env::var("PATH").unwrap_or_default(); + let candidates = login + .into_iter() + .chain(parse_etc_shells(&etc)) + .chain(path_shell_candidates(&path_var)); unix_shells_from(candidates, |p| Path::new(p).is_file()) } @@ -355,6 +391,45 @@ mod tests { ); } + #[test] + fn path_shell_candidates_expand_dirs_in_order_skipping_relative() { + let cands = path_shell_candidates("/opt/homebrew/bin:relative:.:/usr/bin/:"); + // Per shell, one candidate per *absolute* PATH dir, in PATH order, with + // any trailing slash on the dir normalized away. + assert_eq!(cands[0], "/opt/homebrew/bin/fish"); + assert_eq!(cands[1], "/usr/bin/fish"); + assert!(cands.contains(&"/opt/homebrew/bin/nu".to_string())); + assert!(cands.iter().all(|c| c.starts_with('/'))); + assert_eq!(cands.len(), PATH_PROBED_SHELLS.len() * 2); + } + + #[test] + fn unregistered_path_shells_are_detected_after_etc_shells() { + // A brew-installed fish: absent from /etc/shells (and not the login + // shell), present on PATH — must still make the list, after the + // registered shells. zsh exists on PATH too but keeps its /etc/shells + // slot via the basename dedupe. + let etc = ["/bin/zsh".to_string(), "/bin/bash".to_string()]; + let candidates = etc + .into_iter() + .chain(path_shell_candidates("/opt/homebrew/bin:/usr/bin")); + let exists = |p: &str| { + matches!( + p, + "/bin/zsh" | "/bin/bash" | "/opt/homebrew/bin/fish" | "/usr/bin/zsh" + ) + }; + let got = unix_shells_from(candidates, exists); + assert_eq!( + got, + vec![ + DetectedShell::bare("zsh", "/bin/zsh"), + DetectedShell::bare("bash", "/bin/bash"), + DetectedShell::bare("fish", "/opt/homebrew/bin/fish"), + ] + ); + } + #[test] fn parse_wsl_list_decodes_utf16le_and_filters() { // "Ubuntu\r\ndocker-desktop\r\ndocker-desktop-data\r\nDebian\r\n\r\n"