mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
Merge pull request #16 from l0ng-ai/feat/shell-picker
feat(shell): detect installed shells, pick one from the "+" dropdown
This commit is contained in:
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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
|
||||
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
|
||||
default. Shells picked this way aren't remembered across restarts (restored
|
||||
panes re-attach to their still-running shells anyway).
|
||||
|
||||
### Changed
|
||||
|
||||
- The Windows default shell now prefers PowerShell 7 (`pwsh.exe`) when
|
||||
installed — probed across Program Files (x64/x86/ARM), the Microsoft Store,
|
||||
scoop, dotnet tools and `PATH` — and falls back to Windows PowerShell as
|
||||
before. Set `shell` in `config.json` to override, as ever.
|
||||
|
||||
## [0.5.0] - 2026-07-07
|
||||
|
||||
### Added
|
||||
|
||||
+7
-5
@@ -46,9 +46,9 @@ pub struct Config {
|
||||
pub keybindings: HashMap<String, String>,
|
||||
/// Optional shell override for the terminals tty7 spawns. When unset (the
|
||||
/// default), the platform's default shell is used: the user's login shell on
|
||||
/// Unix (via `$SHELL`), and PowerShell on Windows. Set this to run a specific
|
||||
/// shell instead — e.g. `pwsh` / `cmd` / WSL `bash` on Windows, or `fish` /
|
||||
/// `bash` on Unix.
|
||||
/// Unix (via `$SHELL`), and PowerShell on Windows (PowerShell 7 when
|
||||
/// installed, else Windows PowerShell). Set this to run a specific shell
|
||||
/// instead — e.g. `cmd` / WSL `bash` on Windows, or `fish` / `bash` on Unix.
|
||||
pub shell: Option<ShellConfig>,
|
||||
|
||||
// ── Behavior ────────────────────────────────────────────────────────────
|
||||
@@ -228,7 +228,8 @@ impl Default for Config {
|
||||
colors: Colors::default(),
|
||||
keybindings: HashMap::new(),
|
||||
// `None` → the platform default shell (login shell on Unix,
|
||||
// PowerShell on Windows), chosen by the daemon at spawn time.
|
||||
// PowerShell 7 / Windows PowerShell on Windows), chosen by the
|
||||
// daemon at spawn time.
|
||||
shell: None,
|
||||
// Behavior defaults mirror the values previously hardcoded across the
|
||||
// app, so exposing them as config changes nothing until the user opts
|
||||
@@ -449,7 +450,8 @@ pub fn config_dir_path() -> Option<PathBuf> {
|
||||
/// The user's configured shell override, if any, as `(program, args)`. Loaded
|
||||
/// straight from `config.json` so the **daemon** process (which has no GPUI
|
||||
/// `Config` global) can honor it when spawning a PTY. `None` → the daemon picks
|
||||
/// the platform default (login shell on Unix, PowerShell on Windows).
|
||||
/// the platform default (login shell on Unix, PowerShell 7 / Windows PowerShell
|
||||
/// on Windows).
|
||||
pub fn shell_command() -> Option<(String, Vec<String>)> {
|
||||
Config::load().shell.map(|s| (s.program, s.args))
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@ pub mod actions;
|
||||
pub mod config;
|
||||
pub mod osc;
|
||||
pub mod session;
|
||||
pub mod shells;
|
||||
pub mod threads;
|
||||
pub mod update;
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
//! Shell discovery: enumerate the shells installed on this machine so the UI
|
||||
//! can offer them in the new-tab dropdown, and resolve the platform default.
|
||||
//!
|
||||
//! Mirrors Warp's approach (`app/src/util/windows.rs` there): rather than
|
||||
//! asking the user to type a program path into config, probe the well-known
|
||||
//! install locations up front and present what actually exists.
|
||||
//!
|
||||
//! - **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.
|
||||
//! - **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
|
||||
//! distributions via `wsl.exe -l -q`.
|
||||
//!
|
||||
//! Everything effectful (filesystem, env, spawning `wsl.exe`) stays in thin
|
||||
//! wrappers; the parsing/selection logic is pure functions with unit tests.
|
||||
//! Discovery can take a beat (WSL enumeration spawns a process), so callers
|
||||
//! run [`detect_shells`] off the UI thread.
|
||||
|
||||
use std::path::Path;
|
||||
// The probe helpers below build candidate paths; they're Windows-only code.
|
||||
#[cfg(windows)]
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// One launchable shell surfaced in the new-tab dropdown. `program` + `args`
|
||||
/// have the same shape as `config::ShellConfig` / `protocol::ShellSpec`: a
|
||||
/// bare name resolved via `PATH` or an absolute path, plus launch arguments.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DetectedShell {
|
||||
/// Human-readable menu label, e.g. `zsh`, `PowerShell 7`, `WSL · Ubuntu`.
|
||||
pub label: String,
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
impl DetectedShell {
|
||||
fn bare(label: impl Into<String>, program: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
program: program.into(),
|
||||
args: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerate the shells installed on this machine, best-effort. Order is
|
||||
/// meaningful: the entry most likely to be the user's default comes first.
|
||||
/// Runs filesystem probes (and `wsl.exe` on Windows) — call off the UI thread.
|
||||
pub fn detect_shells() -> Vec<DetectedShell> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
detect_unix()
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
detect_windows()
|
||||
}
|
||||
}
|
||||
|
||||
/// The short display name of the shell a *default* spawn resolves to: the
|
||||
/// config override when set, otherwise the platform default (`$SHELL` on Unix,
|
||||
/// the probed PowerShell on Windows). Drives the "Default (zsh)" menu label.
|
||||
pub fn default_shell_name(configured: Option<&str>) -> String {
|
||||
let program = match configured {
|
||||
Some(p) if !p.trim().is_empty() => p.to_string(),
|
||||
_ => {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::env::var("SHELL").unwrap_or_else(|_| "sh".into())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
windows_default_shell().to_string()
|
||||
}
|
||||
}
|
||||
};
|
||||
basename(&program)
|
||||
}
|
||||
|
||||
/// The last path component of `program`, lowercased on Windows and stripped of
|
||||
/// a trailing `.exe` — `C:\...\pwsh.exe` and `/usr/local/bin/fish` both reduce
|
||||
/// to their bare shell name for labels and dedupe keys.
|
||||
fn basename(program: &str) -> String {
|
||||
let base = Path::new(program)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| program.to_string());
|
||||
if cfg!(windows) {
|
||||
let lower = base.to_ascii_lowercase();
|
||||
lower.strip_suffix(".exe").unwrap_or(&lower).to_string()
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse `/etc/shells` content: one absolute path per line, `#` comments and
|
||||
/// blank lines skipped. Pure — the caller supplies the file content.
|
||||
#[cfg_attr(windows, allow(dead_code))]
|
||||
fn parse_etc_shells(content: &str) -> Vec<String> {
|
||||
content
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Order + dedupe the Unix candidate list: keep the first occurrence of each
|
||||
/// basename that `exists` confirms, labelled by that basename. Pure — `exists`
|
||||
/// is injected so tests need no real filesystem.
|
||||
#[cfg_attr(windows, allow(dead_code))]
|
||||
fn unix_shells_from(
|
||||
candidates: impl IntoIterator<Item = String>,
|
||||
exists: impl Fn(&str) -> bool,
|
||||
) -> Vec<DetectedShell> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for path in candidates {
|
||||
if !exists(&path) {
|
||||
continue;
|
||||
}
|
||||
let name = basename(&path);
|
||||
if seen.insert(name.clone()) {
|
||||
out.push(DetectedShell::bare(name, path));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn detect_unix() -> Vec<DetectedShell> {
|
||||
// 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).
|
||||
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));
|
||||
unix_shells_from(candidates, |p| Path::new(p).is_file())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Windows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The Windows shell a *default* spawn launches: PowerShell 7 (`pwsh.exe`)
|
||||
/// when installed, else Windows PowerShell. Probed once and cached — the
|
||||
/// daemon consults this on every pane spawn.
|
||||
#[cfg(windows)]
|
||||
pub fn windows_default_shell() -> &'static str {
|
||||
use std::sync::OnceLock;
|
||||
static DEFAULT: OnceLock<String> = OnceLock::new();
|
||||
DEFAULT.get_or_init(|| {
|
||||
find_pwsh7()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "powershell.exe".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Locate PowerShell 7 the way Warp does: fixed install roots first (Program
|
||||
/// Files x64/x86/ARM, dotnet tools, scoop, the Microsoft Store shim), then a
|
||||
/// `PATH` search as the catch-all.
|
||||
#[cfg(windows)]
|
||||
fn find_pwsh7() -> Option<PathBuf> {
|
||||
let mut roots = Vec::new();
|
||||
for var in ["ProgramFiles", "ProgramFiles(x86)", "ProgramFiles(Arm)"] {
|
||||
if let Some(pf) = std::env::var_os(var).filter(|v| !v.is_empty()) {
|
||||
let pf = PathBuf::from(pf);
|
||||
roots.push(pf.join("PowerShell").join("7"));
|
||||
roots.push(pf.join("PowerShell").join("7-preview"));
|
||||
}
|
||||
}
|
||||
if let Some(home) = std::env::var_os("USERPROFILE").filter(|v| !v.is_empty()) {
|
||||
let home = PathBuf::from(home);
|
||||
roots.push(home.join(".dotnet").join("tools"));
|
||||
roots.push(home.join("scoop").join("shims"));
|
||||
}
|
||||
if let Some(local) = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()) {
|
||||
roots.push(PathBuf::from(local).join("Microsoft").join("WindowsApps"));
|
||||
}
|
||||
pick_first_existing(roots.iter().map(|r| r.join("pwsh.exe")))
|
||||
.or_else(|| find_in_path("pwsh.exe"))
|
||||
}
|
||||
|
||||
/// First candidate that exists on disk. Shared by the per-shell probes.
|
||||
#[cfg(windows)]
|
||||
fn pick_first_existing(candidates: impl IntoIterator<Item = PathBuf>) -> Option<PathBuf> {
|
||||
candidates.into_iter().find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Minimal `PATH` search (no PATHEXT expansion — callers pass the full
|
||||
/// `foo.exe` name).
|
||||
#[cfg(windows)]
|
||||
fn find_in_path(exe: &str) -> Option<PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
std::env::split_paths(&path)
|
||||
.map(|dir| dir.join(exe))
|
||||
.find(|p| p.is_file())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn detect_windows() -> Vec<DetectedShell> {
|
||||
let mut out = Vec::new();
|
||||
let system_root =
|
||||
PathBuf::from(std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into()));
|
||||
|
||||
if let Some(pwsh) = find_pwsh7() {
|
||||
out.push(DetectedShell::bare(
|
||||
"PowerShell 7",
|
||||
pwsh.to_string_lossy().into_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let ps5 = system_root
|
||||
.join("System32")
|
||||
.join("WindowsPowerShell")
|
||||
.join("v1.0")
|
||||
.join("powershell.exe");
|
||||
if ps5.is_file() {
|
||||
out.push(DetectedShell::bare(
|
||||
"Windows PowerShell",
|
||||
ps5.to_string_lossy().into_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let cmd = std::env::var_os("ComSpec")
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_file())
|
||||
.unwrap_or_else(|| system_root.join("System32").join("cmd.exe"));
|
||||
if cmd.is_file() {
|
||||
out.push(DetectedShell::bare(
|
||||
"Command Prompt",
|
||||
cmd.to_string_lossy().into_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(bash) = find_git_bash() {
|
||||
out.push(DetectedShell {
|
||||
label: "Git Bash".into(),
|
||||
program: bash.to_string_lossy().into_owned(),
|
||||
// Interactive login shell — matches Git Bash's own launcher.
|
||||
args: vec!["-i".into(), "-l".into()],
|
||||
});
|
||||
}
|
||||
|
||||
for distro in list_wsl_distros() {
|
||||
out.push(DetectedShell {
|
||||
label: format!("WSL · {distro}"),
|
||||
program: "wsl.exe".into(),
|
||||
// `--cd ~` lands in the distro's home rather than a translated
|
||||
// Windows path the inner shell can't do much with.
|
||||
args: vec!["--distribution".into(), distro, "--cd".into(), "~".into()],
|
||||
});
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Git Bash from the usual Git-for-Windows install roots (machine-wide x64,
|
||||
/// x86, and the per-user installer's home).
|
||||
#[cfg(windows)]
|
||||
fn find_git_bash() -> Option<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
for var in ["ProgramFiles", "ProgramFiles(x86)"] {
|
||||
if let Some(pf) = std::env::var_os(var).filter(|v| !v.is_empty()) {
|
||||
candidates.push(PathBuf::from(pf).join("Git").join("bin").join("bash.exe"));
|
||||
}
|
||||
}
|
||||
if let Some(local) = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()) {
|
||||
candidates.push(
|
||||
PathBuf::from(local)
|
||||
.join("Programs")
|
||||
.join("Git")
|
||||
.join("bin")
|
||||
.join("bash.exe"),
|
||||
);
|
||||
}
|
||||
pick_first_existing(candidates)
|
||||
}
|
||||
|
||||
/// Installed WSL distribution names via `wsl.exe -l -q`, or empty when WSL is
|
||||
/// absent. `CREATE_NO_WINDOW` keeps the probe from flashing a console window
|
||||
/// (we're a GUI process).
|
||||
#[cfg(windows)]
|
||||
fn list_wsl_distros() -> Vec<String> {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
let Ok(output) = std::process::Command::new("wsl.exe")
|
||||
.args(["-l", "-q"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Vec::new();
|
||||
}
|
||||
parse_wsl_list(&output.stdout)
|
||||
}
|
||||
|
||||
/// Decode `wsl.exe -l -q` output — UTF-16LE, one distro per line — skipping
|
||||
/// blanks and Docker Desktop's internal distros. Pure for testability.
|
||||
#[cfg_attr(unix, allow(dead_code))]
|
||||
fn parse_wsl_list(bytes: &[u8]) -> Vec<String> {
|
||||
// UTF-16LE: pair up bytes, tolerate a stray trailing byte.
|
||||
let units: Vec<u16> = bytes
|
||||
.chunks_exact(2)
|
||||
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
let text = String::from_utf16_lossy(&units);
|
||||
text.lines()
|
||||
.map(|l| l.trim_matches(|c: char| c.is_whitespace() || c == '\u{feff}' || c == '\0'))
|
||||
.filter(|l| !l.is_empty() && !l.starts_with("docker-desktop"))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_etc_shells_skips_comments_and_blanks() {
|
||||
let content = "# /etc/shells\n\n/bin/sh\n/bin/bash\n /bin/zsh \n# trailing\n";
|
||||
assert_eq!(
|
||||
parse_etc_shells(content),
|
||||
vec!["/bin/sh", "/bin/bash", "/bin/zsh"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unix_shells_dedupe_by_basename_keeping_first() {
|
||||
// The login shell (seeded first) claims "zsh"; the /etc/shells copy of
|
||||
// zsh under another prefix is dropped; missing files are dropped.
|
||||
let candidates = [
|
||||
"/opt/homebrew/bin/zsh",
|
||||
"/bin/zsh",
|
||||
"/bin/bash",
|
||||
"/usr/local/bin/fish",
|
||||
]
|
||||
.map(String::from);
|
||||
let exists = |p: &str| p != "/usr/local/bin/fish";
|
||||
let got = unix_shells_from(candidates, exists);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
DetectedShell::bare("zsh", "/opt/homebrew/bin/zsh"),
|
||||
DetectedShell::bare("bash", "/bin/bash"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wsl_list_decodes_utf16le_and_filters() {
|
||||
// "Ubuntu\r\ndocker-desktop\r\ndocker-desktop-data\r\nDebian\r\n\r\n"
|
||||
let text = "Ubuntu\r\ndocker-desktop\r\ndocker-desktop-data\r\nDebian\r\n\r\n";
|
||||
let bytes: Vec<u8> = text.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
assert_eq!(parse_wsl_list(&bytes), vec!["Ubuntu", "Debian"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wsl_list_tolerates_bom_and_empty_input() {
|
||||
assert_eq!(parse_wsl_list(&[]), Vec::<String>::new());
|
||||
let text = "\u{feff}Arch\r\n";
|
||||
let bytes: Vec<u8> = text.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
assert_eq!(parse_wsl_list(&bytes), vec!["Arch"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basename_reduces_paths_to_shell_names() {
|
||||
assert_eq!(basename("/usr/local/bin/fish"), "fish");
|
||||
assert_eq!(basename("zsh"), "zsh");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert_eq!(basename(r"C:\Program Files\PowerShell\7\pwsh.exe"), "pwsh");
|
||||
assert_eq!(basename("CMD.EXE"), "cmd");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_shell_name_prefers_the_configured_program() {
|
||||
assert_eq!(default_shell_name(Some("/usr/bin/fish")), "fish");
|
||||
assert_eq!(default_shell_name(Some("pwsh")), "pwsh");
|
||||
// Blank config falls through to the platform default — just assert it
|
||||
// yields *something* non-empty without pinning this host's $SHELL.
|
||||
assert!(!default_shell_name(None).is_empty());
|
||||
assert!(!default_shell_name(Some(" ")).is_empty());
|
||||
}
|
||||
}
|
||||
+51
-21
@@ -35,23 +35,18 @@ use std::time::Duration;
|
||||
use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system};
|
||||
|
||||
use crate::core::osc::OscTokenizer;
|
||||
use crate::daemon::protocol::{DaemonMsg, PaneInfo, WinSize};
|
||||
use crate::daemon::protocol::{DaemonMsg, PaneInfo, ShellSpec, WinSize};
|
||||
use crate::daemon::shell_integration;
|
||||
|
||||
/// The Windows default shell. `powershell.exe` (Windows PowerShell 5.1) ships
|
||||
/// with every supported Windows, so it always resolves — users who prefer
|
||||
/// `pwsh` 7+ or `cmd` can set `shell` in config. This is also the name shell
|
||||
/// integration keys off (see [`default_shell_name`]).
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_DEFAULT_SHELL: &str = "powershell.exe";
|
||||
|
||||
/// The platform default shell command, used when the user hasn't set `shell` in
|
||||
/// `config.json`. On Windows `portable-pty`'s own default is `%COMSPEC%`
|
||||
/// (i.e. `cmd.exe`); we override it to PowerShell so tty7's default matches what
|
||||
/// the docs promise.
|
||||
/// (i.e. `cmd.exe`); we override it to PowerShell — PowerShell 7 (`pwsh`) when
|
||||
/// installed, probed once by `core::shells`, else the `powershell.exe` that
|
||||
/// ships with every supported Windows. Mirrors Warp / Windows Terminal's
|
||||
/// preference for the modern shell.
|
||||
#[cfg(windows)]
|
||||
fn default_prog() -> CommandBuilder {
|
||||
CommandBuilder::new(WINDOWS_DEFAULT_SHELL)
|
||||
CommandBuilder::new(crate::core::shells::windows_default_shell())
|
||||
}
|
||||
|
||||
/// On Unix, defer to `portable-pty`, which launches the user's login shell from
|
||||
@@ -66,10 +61,10 @@ fn default_prog() -> CommandBuilder {
|
||||
/// (`$SHELL` / passwd). On Windows we can't ask the builder: its `get_shell()`
|
||||
/// reports `%ComSpec%` (cmd.exe) regardless of what we actually spawn, so it
|
||||
/// would send integration detection chasing cmd.exe and never engage — return
|
||||
/// our real default (`powershell.exe`) instead.
|
||||
/// the same PowerShell `default_prog()` resolved instead.
|
||||
#[cfg(windows)]
|
||||
fn default_shell_name(_cmd: &CommandBuilder) -> String {
|
||||
WINDOWS_DEFAULT_SHELL.to_string()
|
||||
crate::core::shells::windows_default_shell().to_string()
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
@@ -77,6 +72,17 @@ fn default_shell_name(cmd: &CommandBuilder) -> String {
|
||||
cmd.get_shell()
|
||||
}
|
||||
|
||||
/// Which shell a spawn launches, by precedence: the explicit per-spawn override
|
||||
/// (the new-tab dropdown) > the configured `shell` in `config.json` > `None`,
|
||||
/// meaning the platform default (`default_prog()`). Kept as a function so the
|
||||
/// contract is stated (and tested) in one place.
|
||||
fn choose_shell(
|
||||
spawn_override: Option<ShellSpec>,
|
||||
configured: Option<(String, Vec<String>)>,
|
||||
) -> Option<(String, Vec<String>)> {
|
||||
spawn_override.map(|s| (s.program, s.args)).or(configured)
|
||||
}
|
||||
|
||||
/// Default cap on the replay ring: 8 MiB. Enough to reconstruct a deep screen +
|
||||
/// scrollback for a fresh attach, while bounding daemon memory per pane. When the
|
||||
/// ring is full we drop the *oldest* bytes: a terminal stream is only meaningful
|
||||
@@ -242,24 +248,27 @@ pub struct DaemonPane {
|
||||
|
||||
impl DaemonPane {
|
||||
/// Spawn the user's shell on a fresh PTY in `cwd`, sized to `size`, and start
|
||||
/// its reader thread. `id` is the registry id the server assigns. `on_dead`
|
||||
/// fires (from the reader thread) when the child exits while *nobody is
|
||||
/// attached* — the case where no connection's detach would ever reclaim the
|
||||
/// pane; the server uses it to drop the dead pane from its registry instead
|
||||
/// of leaking the zombie child + replay ring for the daemon's lifetime.
|
||||
/// its reader thread. `id` is the registry id the server assigns. `shell` is
|
||||
/// an explicit per-spawn override (the new-tab dropdown) that outranks the
|
||||
/// configured default — see [`choose_shell`]. `on_dead` fires (from the
|
||||
/// reader thread) when the child exits while *nobody is attached* — the case
|
||||
/// where no connection's detach would ever reclaim the pane; the server uses
|
||||
/// it to drop the dead pane from its registry instead of leaking the zombie
|
||||
/// child + replay ring for the daemon's lifetime.
|
||||
pub fn spawn(
|
||||
id: u64,
|
||||
cwd: Option<PathBuf>,
|
||||
size: WinSize,
|
||||
shell: Option<ShellSpec>,
|
||||
on_dead: impl FnOnce() + Send + 'static,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
let pty_size = pty_size(size);
|
||||
|
||||
let pair = native_pty_system().openpty(pty_size)?;
|
||||
|
||||
// Build the shell command. A configured `shell` wins; otherwise fall back
|
||||
// to the platform default (the login shell on Unix, PowerShell on Windows).
|
||||
let configured = crate::core::config::shell_command();
|
||||
// Build the shell command; `None` means the platform default (the login
|
||||
// shell on Unix, PowerShell on Windows).
|
||||
let configured = choose_shell(shell, crate::core::config::shell_command());
|
||||
let mut cmd = match &configured {
|
||||
Some((program, args)) => {
|
||||
let mut c = CommandBuilder::new(program);
|
||||
@@ -1133,6 +1142,27 @@ fn proc_name(pid: i32) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Spawn shell precedence: explicit override > configured > platform
|
||||
/// default (`None`). Locks the contract stated on [`choose_shell`].
|
||||
#[test]
|
||||
fn choose_shell_prefers_override_then_config_then_default() {
|
||||
let over = ShellSpec {
|
||||
program: "fish".into(),
|
||||
args: vec!["-l".into()],
|
||||
};
|
||||
let cfg = ("zsh".to_string(), vec!["-i".to_string()]);
|
||||
|
||||
// Override wins even when a shell is configured.
|
||||
assert_eq!(
|
||||
choose_shell(Some(over.clone()), Some(cfg.clone())),
|
||||
Some(("fish".to_string(), vec!["-l".to_string()]))
|
||||
);
|
||||
// No override → the configured shell.
|
||||
assert_eq!(choose_shell(None, Some(cfg.clone())), Some(cfg));
|
||||
// Neither → platform default.
|
||||
assert_eq!(choose_shell(None, None), None);
|
||||
}
|
||||
|
||||
/// A reader that finishes is joined and reported done — the common teardown
|
||||
/// path (group-kill closed the slave, the reader EOFed) returns cleanly.
|
||||
#[test]
|
||||
|
||||
+94
-3
@@ -43,6 +43,18 @@ pub struct WinSize {
|
||||
pub cell_h: u16,
|
||||
}
|
||||
|
||||
/// A shell program plus launch arguments, carried by `Spawn` when the user
|
||||
/// picked a specific shell from the new-tab dropdown. Same shape as
|
||||
/// `config::ShellConfig`, but defined here so the wire format doesn't depend
|
||||
/// on the config module's evolution.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ShellSpec {
|
||||
/// Bare name resolved via `PATH` (`"pwsh"`) or an absolute path.
|
||||
pub program: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
/// Metadata for one live pane, returned by `List` for session restore / pickers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PaneInfo {
|
||||
@@ -61,7 +73,13 @@ pub struct PaneInfo {
|
||||
pub enum ClientMsg {
|
||||
/// Create a new pane (spawn a shell) in `cwd`, sized to `size`. The daemon
|
||||
/// replies `Spawned`, then this connection becomes that pane's stream.
|
||||
Spawn { cwd: Option<PathBuf>, size: WinSize },
|
||||
/// `shell` overrides the daemon's default shell resolution (config →
|
||||
/// platform default) when the user picked one from the new-tab dropdown.
|
||||
Spawn {
|
||||
cwd: Option<PathBuf>,
|
||||
size: WinSize,
|
||||
shell: Option<ShellSpec>,
|
||||
},
|
||||
/// Bind this connection to an existing pane and (re)size it. The daemon
|
||||
/// replies with a `Snapshot` then live `Output`.
|
||||
Attach { pane_id: u64, size: WinSize },
|
||||
@@ -126,6 +144,12 @@ mod kind {
|
||||
pub const KILL: u8 = 6;
|
||||
pub const LIST: u8 = 7;
|
||||
pub const SHUTDOWN: u8 = 8;
|
||||
/// `Spawn` with an explicit shell override. A separate kind (rather than a
|
||||
/// new field under `SPAWN`) so a default spawn stays byte-identical on the
|
||||
/// wire: the GUI and the long-lived daemon can be different versions, and
|
||||
/// an old daemon must keep serving new-GUI default spawns. Only picking a
|
||||
/// non-default shell sends this, and only a too-old daemon rejects it.
|
||||
pub const SPAWN_SHELL: u8 = 9;
|
||||
|
||||
// Daemon -> client
|
||||
pub const SPAWNED: u8 = 1;
|
||||
@@ -216,7 +240,19 @@ impl ClientMsg {
|
||||
/// Encode and write this message as one frame.
|
||||
pub fn encode<W: Write>(&self, w: &mut W) -> io::Result<()> {
|
||||
match self {
|
||||
ClientMsg::Spawn { cwd, size } => write_frame(w, kind::SPAWN, &to_json(&(cwd, size))?),
|
||||
// Default spawn keeps the legacy frame (kind + tuple payload)
|
||||
// byte-for-byte so an older daemon still serves it; an explicit
|
||||
// shell rides the newer SPAWN_SHELL frame. See `kind::SPAWN_SHELL`.
|
||||
ClientMsg::Spawn {
|
||||
cwd,
|
||||
size,
|
||||
shell: None,
|
||||
} => write_frame(w, kind::SPAWN, &to_json(&(cwd, size))?),
|
||||
ClientMsg::Spawn {
|
||||
cwd,
|
||||
size,
|
||||
shell: shell @ Some(_),
|
||||
} => write_frame(w, kind::SPAWN_SHELL, &to_json(&(cwd, size, shell))?),
|
||||
ClientMsg::Attach { pane_id, size } => {
|
||||
write_frame(w, kind::ATTACH, &to_json(&(pane_id, size))?)
|
||||
}
|
||||
@@ -234,7 +270,15 @@ impl ClientMsg {
|
||||
Ok(match k {
|
||||
kind::SPAWN => {
|
||||
let (cwd, size) = from_json(&payload)?;
|
||||
ClientMsg::Spawn { cwd, size }
|
||||
ClientMsg::Spawn {
|
||||
cwd,
|
||||
size,
|
||||
shell: None,
|
||||
}
|
||||
}
|
||||
kind::SPAWN_SHELL => {
|
||||
let (cwd, size, shell) = from_json(&payload)?;
|
||||
ClientMsg::Spawn { cwd, size, shell }
|
||||
}
|
||||
kind::ATTACH => {
|
||||
let (pane_id, size) = from_json(&payload)?;
|
||||
@@ -359,6 +403,7 @@ mod tests {
|
||||
ClientMsg::Spawn {
|
||||
cwd: Some(PathBuf::from("/work")),
|
||||
size: SIZE,
|
||||
shell: None,
|
||||
},
|
||||
ClientMsg::Resize(SIZE),
|
||||
ClientMsg::Input(vec![b'l', b's', b'\r']),
|
||||
@@ -413,10 +458,20 @@ mod tests {
|
||||
ClientMsg::Spawn {
|
||||
cwd: Some(PathBuf::from("/tmp/x")),
|
||||
size: SIZE,
|
||||
shell: None,
|
||||
},
|
||||
ClientMsg::Spawn {
|
||||
cwd: None,
|
||||
size: SIZE,
|
||||
shell: None,
|
||||
},
|
||||
ClientMsg::Spawn {
|
||||
cwd: Some(PathBuf::from("/tmp/x")),
|
||||
size: SIZE,
|
||||
shell: Some(ShellSpec {
|
||||
program: "wsl.exe".into(),
|
||||
args: vec!["--distribution".into(), "Ubuntu".into()],
|
||||
}),
|
||||
},
|
||||
ClientMsg::Attach {
|
||||
pane_id: 42,
|
||||
@@ -473,6 +528,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire compatibility across GUI/daemon version skew, both directions:
|
||||
/// a default spawn (`shell: None`) must emit the *legacy* frame — kind
|
||||
/// `SPAWN` with a `(cwd, size)` tuple an old daemon can decode — and a
|
||||
/// hand-built legacy frame must decode with `shell: None`. Locks the
|
||||
/// compat contract documented on `kind::SPAWN_SHELL`.
|
||||
#[test]
|
||||
fn default_spawn_stays_wire_compatible_with_old_daemons() {
|
||||
// New client -> old daemon: encode and pick the frame apart.
|
||||
let msg = ClientMsg::Spawn {
|
||||
cwd: Some(PathBuf::from("/work")),
|
||||
size: SIZE,
|
||||
shell: None,
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
msg.encode(&mut buf).unwrap();
|
||||
let (k, payload) = read_frame(&mut std::io::Cursor::new(&buf)).unwrap();
|
||||
assert_eq!(k, kind::SPAWN, "default spawn must use the legacy kind");
|
||||
// An old daemon deserializes exactly a (cwd, size) tuple.
|
||||
let (cwd, size): (Option<PathBuf>, WinSize) = serde_json::from_slice(&payload).unwrap();
|
||||
assert_eq!(cwd, Some(PathBuf::from("/work")));
|
||||
assert_eq!(size, SIZE);
|
||||
|
||||
// Old client -> new daemon: a hand-built legacy frame decodes to
|
||||
// `shell: None`.
|
||||
let legacy = serde_json::to_vec(&(Some(PathBuf::from("/old")), SIZE)).unwrap();
|
||||
let decoded = ClientMsg::from_frame(kind::SPAWN, legacy).unwrap();
|
||||
assert_eq!(
|
||||
decoded,
|
||||
ClientMsg::Spawn {
|
||||
cwd: Some(PathBuf::from("/old")),
|
||||
size: SIZE,
|
||||
shell: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty-payload binary frame (e.g. an `Input([])`) still round-trips and
|
||||
/// an oversize length is rejected.
|
||||
#[test]
|
||||
|
||||
@@ -164,7 +164,7 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
|
||||
let first = ClientMsg::read(&mut read_stream)?;
|
||||
match first {
|
||||
ClientMsg::Spawn { cwd, size } => {
|
||||
ClientMsg::Spawn { cwd, size, shell } => {
|
||||
let id = registry.alloc_id();
|
||||
// Reclaim a pane whose child exits while *detached* (nobody attached,
|
||||
// so no connection's detach path will ever drop it): remove it from
|
||||
@@ -184,7 +184,7 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
let pane = match DaemonPane::spawn(id, cwd, size, on_dead) {
|
||||
let pane = match DaemonPane::spawn(id, cwd, size, shell, on_dead) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
// Report the failure to the client and close.
|
||||
|
||||
+12
-4
@@ -34,7 +34,7 @@ use alacritty_terminal::term::{Config, Term, TermMode};
|
||||
use alacritty_terminal::vte::ansi;
|
||||
|
||||
use crate::core::osc::OscTokenizer;
|
||||
use crate::daemon::protocol::{ClientMsg, DaemonMsg, WinSize};
|
||||
use crate::daemon::protocol::{ClientMsg, DaemonMsg, ShellSpec, WinSize};
|
||||
use crate::daemon::transport::{self, Stream};
|
||||
|
||||
use super::size::TermSize;
|
||||
@@ -147,13 +147,16 @@ pub struct RemoteTerminal {
|
||||
|
||||
impl RemoteTerminal {
|
||||
/// Connect to the daemon, spawn a fresh pane (shell) sized to `size`, and
|
||||
/// start mirroring it. Returns the terminal plus the daemon-assigned
|
||||
/// `pane_id` (the caller persists it for later session restore / `attach`).
|
||||
/// start mirroring it. `shell` is the user's dropdown pick, overriding the
|
||||
/// daemon's default shell resolution; `None` spawns the default. Returns
|
||||
/// the terminal plus the daemon-assigned `pane_id` (the caller persists it
|
||||
/// for later session restore / `attach`).
|
||||
pub fn spawn(
|
||||
size: TermSize,
|
||||
cell_w: u16,
|
||||
cell_h: u16,
|
||||
cwd: Option<PathBuf>,
|
||||
shell: Option<ShellSpec>,
|
||||
) -> anyhow::Result<(Self, u64)> {
|
||||
let mut stream = connect()?;
|
||||
let win = win_size(size, cell_w, cell_h);
|
||||
@@ -161,7 +164,12 @@ impl RemoteTerminal {
|
||||
// Ask the daemon to create the pane, then read its assigned id back. The
|
||||
// very next frames on this connection are this pane's Snapshot + Output,
|
||||
// which the reader thread (started below) will consume.
|
||||
ClientMsg::Spawn { cwd, size: win }.encode(&mut stream)?;
|
||||
ClientMsg::Spawn {
|
||||
cwd,
|
||||
size: win,
|
||||
shell,
|
||||
}
|
||||
.encode(&mut stream)?;
|
||||
let pane_id = match DaemonMsg::read(&mut stream)? {
|
||||
DaemonMsg::Spawned { pane_id } => pane_id,
|
||||
DaemonMsg::Error(msg) => {
|
||||
|
||||
+34
-5
@@ -29,6 +29,7 @@ use crate::core::actions::{
|
||||
CloseActiveTab, NewTab, SendBackTab, SendTab, SplitDown, SplitRight, ToggleMaximizePane,
|
||||
};
|
||||
use crate::core::config::{Config, NotifyMode};
|
||||
use crate::daemon::protocol::ShellSpec;
|
||||
|
||||
// Terminal-scoped actions dispatched by the right-click context menu. They route
|
||||
// to this view via `.on_action` handlers on the terminal surface; tab/split
|
||||
@@ -59,6 +60,11 @@ pub struct TerminalView {
|
||||
/// so a restart can re-`attach` to the still-running pane (process + scrollback
|
||||
/// intact) instead of spawning a fresh shell.
|
||||
pub pane_id: u64,
|
||||
/// The shell this pane was spawned with when the user picked one from the
|
||||
/// new-tab dropdown; `None` for the default shell and for re-attached
|
||||
/// panes. In-memory only (not persisted) — held so splits of this pane
|
||||
/// inherit the same shell.
|
||||
shell_spec: Option<ShellSpec>,
|
||||
pub focus_handle: FocusHandle,
|
||||
pub font: Font,
|
||||
/// Optional distinct base face for bold cells (from `font_family_bold`), with
|
||||
@@ -364,22 +370,38 @@ impl TerminalView {
|
||||
pub fn new(
|
||||
working_directory: Option<std::path::PathBuf>,
|
||||
restore_pane: Option<u64>,
|
||||
shell: Option<ShellSpec>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> anyhow::Result<Self> {
|
||||
// Provisional size; corrected on the first prepaint once we can measure.
|
||||
// The PTY lives in the daemon now. On session restore (`restore_pane`),
|
||||
// re-`attach` to the still-running pane so its process + scrollback come
|
||||
// back intact; otherwise `spawn` a fresh pane. The caller only passes a
|
||||
// `restore_pane` it has already confirmed alive, so we trust it here.
|
||||
let (terminal, pane_id) = match restore_pane {
|
||||
// back intact; otherwise `spawn` a fresh pane (with the caller's shell
|
||||
// pick, if any). The caller only passes a `restore_pane` it has already
|
||||
// confirmed alive, so we trust it here.
|
||||
let (terminal, pane_id, shell_spec) = match restore_pane {
|
||||
Some(id) => (
|
||||
RemoteTerminal::attach(TermSize::new(80, 24), 8, 17, id)?,
|
||||
id,
|
||||
// An attached pane keeps whatever shell it already runs; the
|
||||
// pick that spawned it (if any) isn't persisted.
|
||||
None,
|
||||
),
|
||||
None => RemoteTerminal::spawn(TermSize::new(80, 24), 8, 17, working_directory)?,
|
||||
None => {
|
||||
let (terminal, id) = RemoteTerminal::spawn(
|
||||
TermSize::new(80, 24),
|
||||
8,
|
||||
17,
|
||||
working_directory,
|
||||
shell.clone(),
|
||||
)?;
|
||||
(terminal, id, shell)
|
||||
}
|
||||
};
|
||||
Ok(Self::with_terminal(terminal, pane_id, window, cx))
|
||||
let mut view = Self::with_terminal(terminal, pane_id, window, cx);
|
||||
view.shell_spec = shell_spec;
|
||||
Ok(view)
|
||||
}
|
||||
|
||||
/// Build the view around an already-connected terminal. Split from [`new`]
|
||||
@@ -548,6 +570,7 @@ impl TerminalView {
|
||||
Self {
|
||||
terminal,
|
||||
pane_id,
|
||||
shell_spec: None,
|
||||
focus_handle,
|
||||
font,
|
||||
font_bold,
|
||||
@@ -611,6 +634,12 @@ impl TerminalView {
|
||||
self.terminal.foreground_cwd()
|
||||
}
|
||||
|
||||
/// The shell this pane was explicitly spawned with (new-tab dropdown pick),
|
||||
/// so splits can inherit it. `None` → the default shell.
|
||||
pub fn shell_spec(&self) -> Option<ShellSpec> {
|
||||
self.shell_spec.clone()
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, ev: AlacEvent, cx: &mut Context<Self>) {
|
||||
// Surface a child-exit/daemon-disconnect noticed by the reader thread into
|
||||
// the field the view reads directly (`self.terminal.exited`).
|
||||
|
||||
+46
-6
@@ -14,6 +14,8 @@ use gpui_component::{ActiveTheme as _, IndexPath, TitleBar};
|
||||
use crate::core::actions::*;
|
||||
use crate::core::config::{Config, NewTabPosition, ShellConfig, color_or, hsla_to_hex6};
|
||||
use crate::core::session::{Session, SessionAxis, SessionPane, SessionTab};
|
||||
use crate::core::shells::DetectedShell;
|
||||
use crate::daemon::protocol::ShellSpec;
|
||||
use crate::terminal::view::{ChildExited, TerminalView};
|
||||
use crate::ui::palette::{Command, CommandKind, PaletteEvent, PaletteView};
|
||||
use crate::ui::pane::{CloseOutcome, Pane};
|
||||
@@ -134,6 +136,10 @@ pub struct Tty7App {
|
||||
/// Keeping something focused keeps keystrokes flowing through the window's
|
||||
/// dispatch path, so ⌘T & friends still reach the root action handlers.
|
||||
pub(crate) home_focus: gpui::FocusHandle,
|
||||
/// Shells found on this machine (`core::shells::detect_shells`), listed in
|
||||
/// the "+" dropdown. Probed once at startup off the UI thread — empty until
|
||||
/// that lands, when the dropdown offers just the default entry.
|
||||
pub(crate) detected_shells: Vec<DetectedShell>,
|
||||
}
|
||||
|
||||
impl Tty7App {
|
||||
@@ -168,7 +174,7 @@ impl Tty7App {
|
||||
// predecessor to inherit from, so start in the app's current
|
||||
// directory (None → default behavior).
|
||||
None => {
|
||||
let first = new_terminal(font_size, None, None, window, cx);
|
||||
let first = new_terminal(font_size, None, None, None, window, cx);
|
||||
(vec![Tab::new(Pane::leaf(first))], 0)
|
||||
}
|
||||
// A saved session (with tabs, or an empty home-page state): rebuild it
|
||||
@@ -193,7 +199,24 @@ impl Tty7App {
|
||||
mod_hint_badges: false,
|
||||
mod_hint_gen: 0,
|
||||
home_focus: cx.focus_handle(),
|
||||
detected_shells: Vec::new(),
|
||||
};
|
||||
// Discover this machine's shells for the "+" dropdown off the UI thread
|
||||
// (the WSL probe on Windows spawns a process, and /etc/shells hits the
|
||||
// filesystem). Until it lands the dropdown offers just the default entry.
|
||||
cx.spawn(async move |this, cx| {
|
||||
let shells = cx
|
||||
.background_spawn(async { crate::core::shells::detect_shells() })
|
||||
.await;
|
||||
// `notify` so the strip re-renders and the dropdown closure
|
||||
// captures the freshly landed list (nothing else is guaranteed to
|
||||
// redraw an idle window).
|
||||
let _ = this.update(cx, |app, cx| {
|
||||
app.detected_shells = shells;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
// Persist the session one last time as the app quits. This captures the
|
||||
// latest state — including a plain `cd` that changed a pane's cwd but
|
||||
// triggered no structural change — so the next launch restores where the
|
||||
@@ -665,6 +688,17 @@ impl Tty7App {
|
||||
}
|
||||
|
||||
pub(crate) fn new_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.new_tab_with_shell(None, window, cx);
|
||||
}
|
||||
|
||||
/// Open a new tab running `shell` — a pick from the "+" dropdown — or the
|
||||
/// default shell when `None` (the plain "+" click / Cmd+T path).
|
||||
pub(crate) fn new_tab_with_shell(
|
||||
&mut self,
|
||||
shell: Option<ShellSpec>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Inherit the cwd of the active tab's focused terminal so the new tab
|
||||
// opens in the same directory the user is currently working in.
|
||||
let cwd = self.tabs.get(self.active).and_then(|t| {
|
||||
@@ -672,7 +706,7 @@ impl Tty7App {
|
||||
.focused_or_first(window, cx)
|
||||
.and_then(|leaf| leaf.read(cx).cwd())
|
||||
});
|
||||
let tab = new_terminal(self.font_size, cwd, None, window, cx);
|
||||
let tab = new_terminal(self.font_size, cwd, None, shell, window, cx);
|
||||
self.maximized = None;
|
||||
let insert_at = self.new_tab_insert_at(cx);
|
||||
self.tabs.insert(insert_at, Tab::new(Pane::leaf(tab)));
|
||||
@@ -694,9 +728,12 @@ impl Tty7App {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The new pane inherits the cwd of the pane being split.
|
||||
// The new pane inherits the cwd — and the shell, when the pane being
|
||||
// split was opened with an explicit pick (a WSL/fish tab splits into
|
||||
// more WSL/fish, not back to the default).
|
||||
let cwd = target.read(cx).cwd();
|
||||
let new = new_terminal(self.font_size, cwd, None, window, cx);
|
||||
let shell = target.read(cx).shell_spec();
|
||||
let new = new_terminal(self.font_size, cwd, None, shell, window, cx);
|
||||
if let Some(tab) = self.tabs.get_mut(self.active) {
|
||||
if tab.pane.split_leaf(&target, axis, new.clone()) {
|
||||
self.maximized = None;
|
||||
@@ -1861,7 +1898,9 @@ fn session_to_pane(
|
||||
// Only restore the pane id when the daemon confirms it's still live;
|
||||
// a stale id (daemon restarted, pane killed) falls back to a spawn.
|
||||
let restore = (*pane_id).filter(|id| alive.contains(id));
|
||||
let view = new_terminal(font_size, cwd.clone(), restore, window, cx);
|
||||
// A shell pick isn't persisted in the session, so a stale pane that
|
||||
// must respawn comes back on the default shell.
|
||||
let view = new_terminal(font_size, cwd.clone(), restore, None, window, cx);
|
||||
Pane::leaf(view)
|
||||
}
|
||||
SessionPane::Split { axis, ratio, a, b } => {
|
||||
@@ -1880,11 +1919,12 @@ fn new_terminal(
|
||||
font_size: f32,
|
||||
working_directory: Option<std::path::PathBuf>,
|
||||
restore_pane: Option<u64>,
|
||||
shell: Option<ShellSpec>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Tty7App>,
|
||||
) -> Entity<TerminalView> {
|
||||
let view = cx.new(|cx| {
|
||||
let mut view = TerminalView::new(working_directory, restore_pane, window, cx)
|
||||
let mut view = TerminalView::new(working_directory, restore_pane, shell, window, cx)
|
||||
.expect("failed to start terminal");
|
||||
// Inherit the current global font size so new panes match existing ones.
|
||||
view.font_size = px(font_size);
|
||||
|
||||
+2
-1
@@ -728,7 +728,8 @@ impl Tty7App {
|
||||
/// Shell section: the program tty7 launches in each new terminal, plus its
|
||||
/// launch arguments. Both apply to *newly spawned* panes/tabs — existing
|
||||
/// shells keep running until closed. An empty program falls back to the
|
||||
/// platform default (the login shell on Unix, PowerShell on Windows).
|
||||
/// platform default (the login shell on Unix; PowerShell 7 when installed,
|
||||
/// else Windows PowerShell, on Windows).
|
||||
fn render_settings_shell(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let muted_fg = cx.theme().muted_foreground;
|
||||
let (program_input, args_input, wd_path_input) = match self.active_settings() {
|
||||
|
||||
+69
-48
@@ -10,8 +10,11 @@ use gpui::{
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::input::Input;
|
||||
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex};
|
||||
use gpui_component::menu::{DropdownMenu as _, PopupMenuItem};
|
||||
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, Size, h_flex};
|
||||
|
||||
use crate::core::config::Config;
|
||||
use crate::daemon::protocol::ShellSpec;
|
||||
use crate::ui::app::{Tab, Tty7App};
|
||||
use crate::ui::hints::tab_badge_label;
|
||||
|
||||
@@ -329,56 +332,74 @@ impl Tty7App {
|
||||
strip = strip.child(chip);
|
||||
}
|
||||
|
||||
// "+" new-tab button — a title-bar tile in the tab row's rhythm.
|
||||
strip =
|
||||
strip.child(
|
||||
self.title_bar_tile("tab-add", IconName::Plus, cx, |this, window, cx| {
|
||||
this.new_tab(window, cx);
|
||||
}),
|
||||
);
|
||||
// "+" new-tab button — click opens the shell picker. The default shell
|
||||
// leads the menu (so the common case is two quick clicks on the same
|
||||
// spot; ⌘T still opens a default tab in one), followed by every shell
|
||||
// discovered on this machine (`detected_shells`, probed at startup).
|
||||
// Built on gpui-component's `DropdownMenu`, which is only implemented
|
||||
// for `Button` — hence a ghost Button restyled to the title bar's 30px
|
||||
// tile rhythm (30px box, 15px glyph, soft corners) rather than the
|
||||
// hand-rolled tile the "+" used to be.
|
||||
let shells = self.detected_shells.clone();
|
||||
let default_name = crate::core::shells::default_shell_name(
|
||||
cx.global::<Config>()
|
||||
.shell
|
||||
.as_ref()
|
||||
.map(|s| s.program.as_str()),
|
||||
);
|
||||
let app = cx.entity().downgrade();
|
||||
strip = strip.child(
|
||||
// Same Windows titlebar note as the chips above: `occlude()` gives
|
||||
// the trigger a BlockMouse hitbox so the TitleBar's HTCAPTION drag
|
||||
// area doesn't swallow the click.
|
||||
div().occlude().flex_shrink_0().child(
|
||||
Button::new("tab-add")
|
||||
.icon(Icon::new(IconName::Plus).size(px(15.)))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.w(px(30.))
|
||||
.h(px(30.))
|
||||
.rounded_lg()
|
||||
.dropdown_menu(move |menu, _window, _cx| {
|
||||
let mut menu = menu.with_size(Size::Small).min_w(px(220.));
|
||||
// Default first — what a bare "new tab" means today,
|
||||
// named so the fallback is legible ("New Tab (zsh)").
|
||||
let open_default = app.clone();
|
||||
menu = menu.item(
|
||||
PopupMenuItem::new(format!("New Tab ({default_name})")).on_click(
|
||||
move |_, window, cx| {
|
||||
if let Some(app) = open_default.upgrade() {
|
||||
app.update(cx, |this, cx| this.new_tab(window, cx));
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
if !shells.is_empty() {
|
||||
menu = menu.separator();
|
||||
}
|
||||
for shell in &shells {
|
||||
let spec = ShellSpec {
|
||||
program: shell.program.clone(),
|
||||
args: shell.args.clone(),
|
||||
};
|
||||
let open = app.clone();
|
||||
menu = menu.item(PopupMenuItem::new(shell.label.clone()).on_click(
|
||||
move |_, window, cx| {
|
||||
if let Some(app) = open.upgrade() {
|
||||
app.update(cx, |this, cx| {
|
||||
this.new_tab_with_shell(Some(spec.clone()), window, cx);
|
||||
});
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
menu
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
strip
|
||||
}
|
||||
|
||||
/// A minimal clickable icon tile sized to sit in the title bar's rhythm:
|
||||
/// chip-height (30px) box, chip-sized (15px) glyph, quiet hover. We hand-roll
|
||||
/// it because gpui-component's Button locks its glyph to 0.75× the box, so it
|
||||
/// can't hit a 30px target with a 15px glyph — Button's xsmall would float a
|
||||
/// 20px square beside the 30px chips. `occlude()` makes the tile a `BlockMouse`
|
||||
/// hitbox — like the chips — so on Windows the click isn't swallowed by the
|
||||
/// TitleBar's `HTCAPTION` drag area; mouse-down + stop_propagation also keeps
|
||||
/// the click off the TitleBar's zoom-on-double-click handler. Shared by the
|
||||
/// "+" new-tab button and the split-pane buttons.
|
||||
fn title_bar_tile<F>(
|
||||
&self,
|
||||
id: &'static str,
|
||||
icon: IconName,
|
||||
cx: &mut Context<Self>,
|
||||
on_click: F,
|
||||
) -> impl IntoElement + use<F>
|
||||
where
|
||||
F: Fn(&mut Self, &mut Window, &mut Context<Self>) + 'static,
|
||||
{
|
||||
div()
|
||||
.id(id)
|
||||
.occlude()
|
||||
.flex_shrink_0()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size(px(30.))
|
||||
.rounded_lg()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.hover(|s| s.bg(cx.theme().muted))
|
||||
.child(Icon::new(icon).size(px(15.)))
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |this, _: &MouseDownEvent, window, cx| {
|
||||
cx.stop_propagation();
|
||||
on_click(this, window, cx);
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user